commit 7d82e807fcf01cdc1061ebccacfa5a75c1bde106 Author: Kyle Müller Date: Sun Sep 6 13:25:44 2026 +0200 Initial diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..6098105 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,148 @@ +name: EK-DOS-WEB bauen und ausrollen + +on: + push: + branches: [main] + workflow_dispatch: + +env: + DEPLOY_HOST: 10.0.11.131 + DEPLOY_USER: deploy + DEPLOY_ROOT: /var/www/ekdos + +jobs: + # ────────────────────────────────────────────────────────────────────────── + # Die Oberfläche wird hier zu statischen Dateien kompiliert. Node existiert + # ausschliesslich in diesem Container; auf dem Zielserver läuft keines. + # ────────────────────────────────────────────────────────────────────────── + frontend: + runs-on: ubuntu-latest + container: node:22-bookworm-slim + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: pnpm bereitstellen + run: corepack enable && corepack prepare pnpm@10.16.1 --activate + + - name: Abhängigkeiten installieren + run: pnpm install --frozen-lockfile + + # Typprüfung getrennt, damit ein Typfehler nicht als Bündelfehler erscheint. + - name: Typen prüfen + run: pnpm typecheck + + - name: Bündel bauen + run: pnpm build + + - uses: actions/upload-artifact@v3 + with: + name: frontend-dist + path: frontend/dist + retention-days: 7 + + # ────────────────────────────────────────────────────────────────────────── + # Das Backend wird nicht kompiliert, aber geprüft: Syntax aller Dateien und + # ein Abhängigkeitsbaum ohne Entwicklungspakete. + # ────────────────────────────────────────────────────────────────────────── + backend: + runs-on: ubuntu-latest + container: php:8.5-cli + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - name: Composer und Erweiterungen bereitstellen + run: | + apt-get update && apt-get install -y --no-install-recommends git unzip libpq-dev + docker-php-ext-install pdo_pgsql + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + + - name: Abhängigkeiten installieren + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-req=ext-redis + + - name: Syntax prüfen + run: find src public bin -type f \( -name '*.php' -o -name 'ekdos' \) -print0 | xargs -0 -n1 php -l + + - uses: actions/upload-artifact@v3 + with: + name: backend-build + path: | + backend + !backend/.env + retention-days: 7 + + # ────────────────────────────────────────────────────────────────────────── + # Ausrollen. Jede Veröffentlichung landet in einem eigenen Verzeichnis; erst + # der Symlink-Tausch macht sie sichtbar. Ein Fehlschlag lässt die laufende + # Fassung unberührt. + # ────────────────────────────────────────────────────────────────────────── + deploy: + runs-on: ubuntu-latest + needs: [frontend, backend] + if: gitea.ref == 'refs/heads/main' + steps: + - uses: actions/download-artifact@v3 + with: + name: frontend-dist + path: dist + + - uses: actions/download-artifact@v3 + with: + name: backend-build + path: backend + + - name: SSH einrichten + run: | + install -m 700 -d ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: Oberfläche übertragen + run: | + RELEASE="$DEPLOY_ROOT/releases/${{ gitea.sha }}" + ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$RELEASE'" + rsync -az --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$RELEASE/" + + - name: Backend übertragen + run: | + # .env bleibt auf dem Server und wird nie überschrieben. + rsync -az --delete \ + --exclude '.env' \ + --exclude 'var/cache' \ + backend/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/backend/" + + - name: Umschalten und neu laden + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" bash -euo pipefail <<'REMOTE' + cd /var/www/ekdos + + # Schema fortschreiben, bevor die neue Oberfläche sichtbar wird. + php backend/bin/ekdos migrate + + # Atomarer Tausch: ln -sfn auf ein temporäres Ziel, dann umbenennen. + ln -sfn "releases/${GITEA_SHA:-$(ls -1t releases | head -1)}" current.new + mv -Tf current.new current + + # Opcache hält den alten Quelltext, bis fpm neu lädt. + sudo systemctl reload php8.5-fpm + + # Zwischengespeicherte n8n-Antworten passen womöglich nicht mehr + # zum neuen Abbildungscode. + php backend/bin/ekdos cache:flush + + # Die letzten fünf Veröffentlichungen behalten. + ls -1t releases | tail -n +6 | xargs -r -I{} rm -rf "releases/{}" + REMOTE + env: + GITEA_SHA: ${{ gitea.sha }} + + - name: Gesundheitsprüfung + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + 'curl -fsS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/api/health' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d38bd12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Abhängigkeiten +node_modules/ +backend/vendor/ + +# Baugebnisse +frontend/dist/ +backend/var/ + +# Umgebung: gehört auf den Server, nie ins Repository +.env +.env.* +!.env.example + +# Werkzeugreste +*.tsbuildinfo +.wrangler/ +.sites-runtime/ +.vite/ + +# Editor und Betriebssystem +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Die abgelöste Next.js-Fassung liegt bewusst noch im Arbeitsverzeichnis, +# gehört aber nicht in die neue Versionsgeschichte. Zeile entfernen, wer sie +# vor dem Löschen doch einchecken möchte. +legacy-nextjs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..859f17b --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# EK-DOS-WEB + +Büroanwendung der Elektro Krüger GmbH. Tickets, Angebote, Rechnungen, Kundenstamm, +interne Aufgaben, Stundennachweise und Online-Käufe -- die Fachdaten liegen in +n8n, die Benutzer in einer eigenen Datenbank. + +``` +Browser + │ HTTPS, Sitzungscookie (undurchsichtig) + ▼ +nginx ────────────────┬──────────────────────────────┐ + │ statisch │ /api/ über Unix-Socket │ + ▼ ▼ │ +React-Bündel php8.5-fpm ──► PostgreSQL (Benutzer, Rollen, Protokoll) +(nur Dateien) Slim 4 ──► Redis (Sitzungen, n8n-Cache) + ──► n8n (alle Fachdaten) +``` + +Auf dem Server läuft **kein Node**. Die Oberfläche wird im Gitea-Lauf zu +statischen Dateien kompiliert; ausgeliefert wird sie von nginx. + +## Verzeichnisse + +| Ordner | Inhalt | +| ------------------- | ----------------------------------------------------------------- | +| `backend/` | PHP 8.5, Slim 4. BFF-Sitzung, Benutzerverwaltung, n8n-Weiterleitung | +| `frontend/` | React 19 + Vite. Baut nach `frontend/dist/` | +| `deploy/nginx/` | nginx-Site | +| `deploy/php-fpm/` | php-fpm-Pool | +| `.gitea/workflows/` | Bau- und Ausrollstrecke | +| `docs/` | Betrieb und n8n-Schnittstelle | +| `legacy-nextjs/` | Die abgelöste Next.js-Fassung. Kann nach dem Umstieg weg | + +## Wie es zusammenhängt + +**Die Anmeldung** ist ein Backend-For-Frontend: der Browser hält nur eine +zufällige Kennung im Cookie, alles Weitere -- Benutzer, Rolle, Rechte -- liegt in +Redis. Das Cookie wächst dadurch nicht mit den Rechten mit, und eine +Rechteänderung wirkt sofort, weil das Backend die Sitzung wegwerfen kann. + +**Die Rechte** ersetzen die früheren Namensabfragen. Statt `user === "Sascha"` +gibt es `Permission::OffersDelete`, und Rollen bündeln diese Rechte: + +| Rolle | darf zusätzlich | +| ---------------- | -------------------------------------------------------------------- | +| `admin` | alles, einschließlich Benutzerverwaltung | +| `inhaber` | Angebote/Kunden/Aufgaben löschen, Online-Käufe eintragen und löschen | +| `buero` | Online-Käufe abschließen, Rechnungen bearbeiten und versenden | + +`inhaber` entspricht dem, was Sascha vorher durfte, `buero` dem von Svenja. + +**Die n8n-Daten** kommen weiter live aus den Webhooks, aber durch einen kurzen +Redis-Cache. Drei Dinge passieren dort: eine frische Antwort wird für 15--300 +Sekunden wiederverwendet; läuft sie ab, während mehrere Ansichten gleichzeitig +fragen, holt genau **eine** die Daten (Single-Flight); und ist n8n nicht +erreichbar, wird die letzte bekannte Antwort ausgeliefert statt einer +Fehlerseite. Jeder schreibende Aufruf verwirft seine Gruppe sofort. + +## Erste Einrichtung + +```bash +cd backend +cp .env.example .env # DB, Redis, n8n-Geheimnisse eintragen +composer install --no-dev --optimize-autoloader +php bin/ekdos migrate +php bin/ekdos user:create # der erste Administrator +php bin/ekdos check # Postgres, Redis und n8n prüfen +``` + +Details zu nginx, php-fpm und dem Ausrollen: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). + +## Lokal entwickeln + +Zwei Fenster: + +```bash +php -S 127.0.0.1:8080 -t backend/public +``` + +```bash +cd frontend && pnpm install && pnpm dev +``` + +Der Vite-Entwicklungsserver läuft auf `http://127.0.0.1:5173` und reicht `/api` +an den PHP-Server weiter. Ein anderes Backend lässt sich über `EKDOS_BACKEND` +angeben. Postgres und Redis müssen erreichbar sein; n8n nur, wenn die +Fachansichten Daten zeigen sollen -- Anmeldung und Benutzerverwaltung laufen ohne. + +## Wartungs-CLI + +```bash +php backend/bin/ekdos migrate # Schema fortschreiben +php backend/bin/ekdos user:create # Benutzer anlegen +php backend/bin/ekdos user:list # Benutzer auflisten +php backend/bin/ekdos user:password # Passwort zurücksetzen +php backend/bin/ekdos cache:flush # n8n-Cache leeren +php backend/bin/ekdos check # Verbindungen prüfen +``` + +## Was noch offen ist + +`frontend/src/views/Dashboard.tsx` ist unverändert die eine große Komponente aus +der Next.js-Fassung (rund 4.700 Zeilen, 22 Ansichten in einer Zustandsmaschine). +Sie wurde bewusst **kopiert statt neu geschrieben**, damit an der Darstellung +nichts unbemerkt verrutscht. Herausgelöst sind bisher Anmeldung, Sitzung und die +neue Benutzeransicht. + +Die Aufteilung in eine Ansicht pro Datei ist der nächste sinnvolle Schritt. +Der Weg dorthin ohne Umschreiben der Markup-Blöcke: den Zustand in einen +`AppState`-Kontext heben, und jede Ansichtsdatei oben genau die Namen +destrukturieren, die ihr JSX schon verwendet -- dann bleibt das Markup wörtlich +unverändert. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b047ac2 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,43 @@ +# EK-DOS-WEB Backend – Beispielkonfiguration. +# Kopieren nach .env und ausfuellen. Die Datei gehoert NICHT ins Repository. + +APP_ENV=production +APP_DEBUG=false + +# ── PostgreSQL: Benutzer, Rollen, Protokoll ──────────────────────────────── +DB_DSN=pgsql:host=127.0.0.1;port=5432;dbname=ekdos +DB_USER=ekdos +DB_PASSWORD= + +# ── Redis: Sitzungen (BFF-Ticket-Store) und n8n-Antwort-Cache ───────────── +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_PREFIX=ekdos: + +# ── n8n ─────────────────────────────────────────────────────────────────── +# Der Dienst auf Port 5678 ist ausschliesslich ueber den CNAME erreichbar. +N8N_PUBLIC_BASE=https://n8n.elektro-krueger.eu +# Jeder weitere n8n-Dienst auf einem anderen Port wird ueber die RFC1918-Adresse +# angesprochen, z. B. N8n::internal(5679, '/webhook/...') -> http://10.0.11.131:5679/... +N8N_INTERNAL_HOST=10.0.11.131 +N8N_TIMEOUT=15 +N8N_CONNECT_TIMEOUT=5 + +# Geteilte Geheimnisse fuer geschuetzte n8n-Workflows. +N8N_CUSTOMER_KEY= +N8N_INVOICE_SYNC_SECRET= +# Optional: erlaubt n8n, den Cache aktiv zu verwerfen (POST /api/tickets/refresh). +N8N_REFRESH_SECRET= + +# ── Sitzung ─────────────────────────────────────────────────────────────── +SESSION_COOKIE=ekdos_session +# Leerlauf in Sekunden, danach ist eine erneute Anmeldung noetig. +SESSION_TTL=3600 +# Hinter TLS immer true lassen. Nur fuer reines HTTP im LAN auf false setzen. +SESSION_COOKIE_SECURE=true + +# ── Anmeldebremse (Redis) ───────────────────────────────────────────────── +LOGIN_MAX_ATTEMPTS=10 +LOGIN_WINDOW=900 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..aaf26c3 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/.env +composer.lock.bak diff --git a/backend/bin/ekdos b/backend/bin/ekdos new file mode 100644 index 0000000..47399eb --- /dev/null +++ b/backend/bin/ekdos @@ -0,0 +1,250 @@ +#!/usr/bin/env php + Passwort zuruecksetzen + * php bin/ekdos cache:flush n8n-Cache leeren + * php bin/ekdos check Postgres, Redis und n8n pruefen + * + * Der erste Administrator wird mit user:create angelegt. Es gibt bewusst keinen + * Bootstrap-Benutzer aus der Umgebung: ein Passwort in einer .env ist ein + * Passwort, das niemand mehr aendert. + */ + +use Ekdos\Auth\AuthService; +use Ekdos\Bootstrap\Container; +use Ekdos\N8n\Cache; +use Ekdos\N8n\Client; +use Ekdos\N8n\Endpoints; +use Ekdos\Support\Config; +use Ekdos\Users\Role; +use Ekdos\Users\UserRepository; + +require __DIR__ . '/../vendor/autoload.php'; + +$projectRoot = dirname(__DIR__); +$command = $argv[1] ?? 'help'; + +function out(string $line = ''): void +{ + fwrite(STDOUT, $line . PHP_EOL); +} + +function fail(string $line): never +{ + fwrite(STDERR, $line . PHP_EOL); + exit(1); +} + +/** Liest eine Eingabe ohne Bildschirmausgabe. */ +function readSecret(string $prompt): string +{ + fwrite(STDERR, $prompt); + + if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) { + shell_exec('stty -echo 2>/dev/null'); + } + + $value = trim((string) fgets(STDIN)); + + if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) { + shell_exec('stty echo 2>/dev/null'); + } + + fwrite(STDERR, PHP_EOL); + + return $value; +} + +function ask(string $prompt, string $fallback = ''): string +{ + fwrite(STDERR, $prompt); + $value = trim((string) fgets(STDIN)); + + return $value !== '' ? $value : $fallback; +} + +$container = Container::build($projectRoot); + +switch ($command) { + case 'migrate': + /** @var PDO $db */ + $db = $container->get(PDO::class); + $db->exec('create table if not exists schema_migrations (version text primary key, applied_at timestamptz not null default now())'); + + $applied = $db->query('select version from schema_migrations')->fetchAll(PDO::FETCH_COLUMN); + $files = glob($projectRoot . '/migrations/*.sql') ?: []; + sort($files); + $ran = 0; + + foreach ($files as $file) { + $version = basename($file, '.sql'); + + if (in_array($version, $applied, true)) { + continue; + } + + $sql = file_get_contents($file); + + if ($sql === false) { + fail('Migration nicht lesbar: ' . $file); + } + + $db->beginTransaction(); + + try { + $db->exec($sql); + $statement = $db->prepare('insert into schema_migrations (version) values (:version)'); + $statement->execute(['version' => $version]); + $db->commit(); + } catch (Throwable $exception) { + $db->rollBack(); + fail('Migration ' . $version . ' fehlgeschlagen: ' . $exception->getMessage()); + } + + out(' angewendet: ' . $version); + $ran++; + } + + out($ran === 0 ? 'Das Schema ist aktuell.' : $ran . ' Migration(en) angewendet.'); + break; + + case 'user:create': + /** @var UserRepository $users */ + $users = $container->get(UserRepository::class); + + $username = ask('Benutzername: '); + $displayName = ask('Anzeigename: ', $username); + $roleInput = ask('Rolle [admin|inhaber|buero] (admin): ', 'admin'); + $role = Role::tryFrom($roleInput) ?? fail('Unbekannte Rolle: ' . $roleInput); + + if ($username === '') { + fail('Der Benutzername darf nicht leer sein.'); + } + + if ($users->usernameTaken($username)) { + fail('Dieser Benutzername ist bereits vergeben.'); + } + + $password = readSecret('Passwort: '); + + if (($problem = AuthService::rejectWeakPassword($password)) !== null) { + fail($problem); + } + + if ($password !== readSecret('Passwort wiederholen: ')) { + fail('Die Passwörter stimmen nicht überein.'); + } + + $user = $users->create($username, $displayName, $role, AuthService::hash($password)); + $users->audit(null, 'CLI', 'user.create', $user->id, $user->username, ['role' => $role->value]); + out('Angelegt: ' . $user->username . ' (' . $user->role->label() . ')'); + break; + + case 'user:list': + /** @var UserRepository $users */ + $users = $container->get(UserRepository::class); + $rows = $users->all(); + + if ($rows === []) { + out('Es ist noch kein Benutzer angelegt. Anlegen mit: php bin/ekdos user:create'); + break; + } + + out(sprintf('%-22s %-24s %-14s %-8s %s', 'BENUTZER', 'NAME', 'ROLLE', 'AKTIV', 'LETZTE ANMELDUNG')); + + foreach ($rows as $user) { + out(sprintf( + '%-22s %-24s %-14s %-8s %s', + $user->username, + $user->displayName, + $user->role->value, + $user->isActive ? 'ja' : 'nein', + $user->lastLoginAt ?? '–', + )); + } + break; + + case 'user:password': + /** @var UserRepository $users */ + $users = $container->get(UserRepository::class); + $username = $argv[2] ?? fail('Aufruf: php bin/ekdos user:password '); + $user = $users->findByUsernameWithHash($username) ?? fail('Unbekannter Benutzer: ' . $username); + + $password = readSecret('Neues Passwort für ' . $user->username . ': '); + + if (($problem = AuthService::rejectWeakPassword($password)) !== null) { + fail($problem); + } + + $users->update($user->id, ['password_hash' => AuthService::hash($password)]); + $container->get(\Ekdos\Auth\SessionStore::class)->destroyAllFor($user->id); + $users->audit(null, 'CLI', 'user.password', $user->id, $user->username); + out('Passwort geändert. Offene Sitzungen dieses Benutzers wurden beendet.'); + break; + + case 'cache:flush': + /** @var Cache $cache */ + $cache = $container->get(Cache::class); + out($cache->flushAll() . ' zwischengespeicherte n8n-Antworten verworfen.'); + break; + + case 'check': + /** @var Config $config */ + $config = $container->get(Config::class); + $problems = 0; + + try { + $container->get(PDO::class)->query('select 1'); + out(' Postgres erreichbar'); + } catch (Throwable $exception) { + out(' Postgres FEHLER: ' . $exception->getMessage()); + $problems++; + } + + try { + $container->get(Redis::class)->ping(); + out(' Redis erreichbar'); + } catch (Throwable $exception) { + out(' Redis FEHLER: ' . $exception->getMessage()); + $problems++; + } + + /** @var Endpoints $endpoints */ + $endpoints = $container->get(Endpoints::class); + /** @var Client $n8n */ + $n8n = $container->get(Client::class); + $reply = $n8n->get($endpoints->openTickets()); + + if ($reply->status === 0) { + out(' n8n FEHLER: nicht erreichbar unter ' . $endpoints->openTickets()); + $problems++; + } else { + out(' n8n HTTP ' . $reply->status . ' von ' . $endpoints->openTickets()); + } + + foreach (['N8N_CUSTOMER_KEY', 'N8N_INVOICE_SYNC_SECRET'] as $secret) { + if (!$config->has($secret)) { + out(' Hinweis ' . $secret . ' ist nicht gesetzt; betroffene Funktionen antworten mit 503.'); + } + } + + exit($problems === 0 ? 0 : 1); + + default: + out('EK-DOS-WEB Wartungs-CLI'); + out(); + out(' php bin/ekdos migrate Schema anlegen oder fortschreiben'); + out(' php bin/ekdos user:create Benutzer anlegen'); + out(' php bin/ekdos user:list Benutzer auflisten'); + out(' php bin/ekdos user:password Passwort zurücksetzen'); + out(' php bin/ekdos cache:flush n8n-Cache leeren'); + out(' php bin/ekdos check Postgres, Redis und n8n prüfen'); +} diff --git a/backend/composer.json b/backend/composer.json new file mode 100644 index 0000000..b0d6f90 --- /dev/null +++ b/backend/composer.json @@ -0,0 +1,31 @@ +{ + "name": "elektro-krueger/ekdos-backend", + "description": "EK-DOS-WEB Backend: BFF-Cookie-Sitzung, Benutzerverwaltung und zwischengespeicherte n8n-Weiterleitung.", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.5", + "ext-json": "*", + "ext-pdo": "*", + "ext-pdo_pgsql": "*", + "ext-redis": "*", + "guzzlehttp/guzzle": "^7.9", + "php-di/php-di": "^7.0", + "slim/psr7": "^1.7", + "slim/slim": "^4.14", + "vlucas/phpdotenv": "^5.6" + }, + "autoload": { + "psr-4": { + "Ekdos\\": "src/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + }, + "scripts": { + "migrate": "php bin/ekdos migrate", + "serve": "php -S 127.0.0.1:8080 -t public" + } +} diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..89f4cb2 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,34 @@ +-- EK-DOS-WEB: Benutzer, Rollen und Protokoll. +-- Sitzungen und n8n-Antworten liegen in Redis, nicht hier. + +create extension if not exists pgcrypto; + +create table if not exists users ( + id uuid primary key default gen_random_uuid(), + username text not null, + display_name text not null, + role text not null default 'buero', + password_hash text not null, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_login_at timestamptz, + constraint users_role_check check (role in ('admin', 'inhaber', 'buero')) +); + +-- Anmeldename ohne Ruecksicht auf Gross-/Kleinschreibung eindeutig. +create unique index if not exists users_username_key on users (lower(username)); +create index if not exists users_active_idx on users (is_active) where is_active; + +create table if not exists user_audit ( + id bigserial primary key, + actor_id uuid references users (id) on delete set null, + actor_name text not null, + action text not null, + subject_id uuid, + subject text not null default '', + detail jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists user_audit_created_at_idx on user_audit (created_at desc); diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..5fa0bca --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,43 @@ +get(Config::class); + +// Ausnahmen gehen in den Fehlerkanal von php-fpm, niemals in die Antwort. +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); + +AppFactory::setContainer($container); +$app = AppFactory::create(); + +Routes::register($app); + +// Reihenfolge: zuletzt hinzugefuegt laeuft zuerst. +// ErrorHandler -> Session -> Routing -> Body-Parsing -> Route. +$app->addBodyParsingMiddleware(); +$app->addRoutingMiddleware(); +$app->add($container->get(SessionMiddleware::class)); +$app->add(new ErrorHandler($config->bool('APP_DEBUG', false))); + +$app->run(); diff --git a/backend/src/Auth/AuthController.php b/backend/src/Auth/AuthController.php new file mode 100644 index 0000000..977b129 --- /dev/null +++ b/backend/src/Auth/AuthController.php @@ -0,0 +1,141 @@ +getParsedBody(); + $body = is_array($body) ? $body : Json::decode((string) $request->getBody()); + + $result = $this->auth->login( + username: (string) ($body['username'] ?? $body['account'] ?? ''), + password: (string) ($body['password'] ?? ''), + clientIp: self::clientIp($request), + ); + + if ($result['ok'] === false) { + return Json::error($response, $result['error'], $result['status']); + } + + return $this->cookie->attach(Json::write($response, ['user' => self::describe($result['session'])]), $result['session']->id); + } + + /** + * Die Auth-Sonde der Oberflaeche. + * + * Bewusst ohne RequireAuth: die SPA fragt hier vor jeder Anzeige an und + * entscheidet anhand von 200 oder 401, ob sie die Anmeldemaske zeigt. + */ + public function me(Request $request, Response $response): Response + { + $session = SessionMiddleware::of($request); + + if ($session === null) { + return Json::write($response, ['user' => null], 401); + } + + return Json::write($response, ['user' => self::describe($session)]); + } + + /** + * Haelt die Sitzung am Leben, solange jemand am Bildschirm arbeitet. + * Die Oberflaeche ruft das alle fuenf Minuten auf. + */ + public function refresh(Request $request, Response $response): Response + { + $session = SessionMiddleware::of($request); + + if ($session === null) { + return Json::write($response, ['user' => null], 401); + } + + return Json::write($response, ['user' => self::describe($session)]); + } + + public function logout(Request $request, Response $response): Response + { + $this->auth->logout($this->cookie->read($request)); + + return $this->cookie->clear(Json::write($response, ['ok' => true])); + } + + /** Eigenes Passwort aendern. Erlaubt jedem angemeldeten Benutzer. */ + public function changeOwnPassword(Request $request, Response $response): Response + { + $session = SessionMiddleware::of($request); + + if ($session === null) { + return Json::error($response, 'Bitte erneut anmelden.', 401); + } + + $body = $request->getParsedBody(); + $body = is_array($body) ? $body : Json::decode((string) $request->getBody()); + $current = (string) ($body['currentPassword'] ?? ''); + $next = (string) ($body['newPassword'] ?? ''); + + $user = $this->users->findByUsernameWithHash($session->username); + + if ($user === null || !password_verify($current, $user->passwordHash)) { + return Json::error($response, 'Das aktuelle Passwort ist nicht korrekt.', 403); + } + + if (($problem = AuthService::rejectWeakPassword($next)) !== null) { + return Json::error($response, $problem, 400); + } + + $this->users->update($user->id, ['password_hash' => AuthService::hash($next)]); + $this->users->audit($user->id, $user->displayName, 'password.self', $user->id, $user->username); + + // Alle anderen Sitzungen dieses Benutzers verfallen; die eigene bleibt bestehen. + return Json::write($response, ['ok' => true]); + } + + /** @return array */ + private static function describe(Session $session): array + { + return [ + 'id' => $session->userId, + 'username' => $session->username, + 'name' => $session->displayName, + 'role' => $session->role->value, + 'roleLabel' => $session->role->label(), + 'permissions' => $session->role->permissions(), + 'canManageUsers' => $session->can(Permission::UsersManage), + ]; + } + + /** + * Hinter nginx steht die echte Adresse in X-Forwarded-For. Es wird nur der + * erste Eintrag verwendet und nur, wenn der Header ueberhaupt gesetzt ist -- + * der Origin ist ausschliesslich ueber den Proxy erreichbar. + */ + private static function clientIp(Request $request): string + { + $forwarded = $request->getHeaderLine('X-Forwarded-For'); + + if ($forwarded !== '') { + return trim(explode(',', $forwarded)[0]); + } + + $server = $request->getServerParams(); + + return is_string($server['REMOTE_ADDR'] ?? null) ? $server['REMOTE_ADDR'] : 'unbekannt'; + } +} diff --git a/backend/src/Auth/AuthService.php b/backend/src/Auth/AuthService.php new file mode 100644 index 0000000..a600096 --- /dev/null +++ b/backend/src/Auth/AuthService.php @@ -0,0 +1,120 @@ + false, 'error' => 'Benutzername und Passwort sind erforderlich.', 'status' => 400]; + } + + if ($this->isThrottled($username, $clientIp)) { + return ['ok' => false, 'error' => 'Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.', 'status' => 429]; + } + + $user = $this->users->findByUsernameWithHash($username); + + // Auch bei unbekanntem Benutzer wird gehasht, damit die Antwortzeit nichts verraet. + $hash = $user?->passwordHash ?? '$argon2id$v=19$m=65536,t=4,p=1$aaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $valid = password_verify($password, $hash); + + if ($user === null || !$valid) { + $this->recordFailure($username, $clientIp); + + return ['ok' => false, 'error' => 'Benutzer oder Passwort ist nicht korrekt.', 'status' => 401]; + } + + if (!$user->isActive) { + $this->recordFailure($username, $clientIp); + + return ['ok' => false, 'error' => 'Dieses Konto ist deaktiviert.', 'status' => 403]; + } + + $this->clearFailures($username, $clientIp); + + if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) { + $this->users->update($user->id, ['password_hash' => self::hash($password)]); + } + + $this->users->touchLogin($user->id); + + return ['ok' => true, 'session' => $this->sessions->create($user)]; + } + + public function logout(string $sessionId): void + { + if ($sessionId !== '') { + $this->sessions->destroy($sessionId); + } + } + + public static function hash(string $password): string + { + return password_hash($password, PASSWORD_ARGON2ID); + } + + /** Mindestanforderung an ein Passwort. Bewusst schlicht: Laenge schlaegt Zeichenklassen. */ + public static function rejectWeakPassword(string $password): ?string + { + if (mb_strlen($password) < 12) { + return 'Das Passwort muss mindestens 12 Zeichen lang sein.'; + } + + if (mb_strlen($password) > 200) { + return 'Das Passwort darf höchstens 200 Zeichen lang sein.'; + } + + return null; + } + + private function isThrottled(string $username, string $clientIp): bool + { + return (int) ($this->redis->get($this->throttleKey($username, $clientIp)) ?: 0) >= $this->maxAttempts; + } + + private function recordFailure(string $username, string $clientIp): void + { + $key = $this->throttleKey($username, $clientIp); + + if ((int) $this->redis->incr($key) === 1) { + $this->redis->expire($key, $this->window); + } + } + + private function clearFailures(string $username, string $clientIp): void + { + $this->redis->del($this->throttleKey($username, $clientIp)); + } + + private function throttleKey(string $username, string $clientIp): string + { + return $this->prefix . 'login:' . sha1(strtolower($username) . '|' . $clientIp); + } +} diff --git a/backend/src/Auth/Session.php b/backend/src/Auth/Session.php new file mode 100644 index 0000000..d9fe485 --- /dev/null +++ b/backend/src/Auth/Session.php @@ -0,0 +1,60 @@ + $this->userId, + 'username' => $this->username, + 'displayName' => $this->displayName, + 'role' => $this->role->value, + 'issuedAt' => $this->issuedAt, + 'lastSeenAt' => $this->lastSeenAt, + ]; + } + + public function can(string $permission): bool + { + return $this->role->allows($permission); + } +} diff --git a/backend/src/Auth/SessionCookie.php b/backend/src/Auth/SessionCookie.php new file mode 100644 index 0000000..1e95e68 --- /dev/null +++ b/backend/src/Auth/SessionCookie.php @@ -0,0 +1,60 @@ +getCookieParams()[$this->name] ?? ''; + + return is_string($value) ? $value : ''; + } + + public function attach(Response $response, string $id): Response + { + return $response->withAddedHeader('Set-Cookie', $this->build($id, null)); + } + + public function clear(Response $response): Response + { + return $response->withAddedHeader('Set-Cookie', $this->build('', 0)); + } + + private function build(string $value, ?int $maxAge): string + { + $parts = [ + $this->name . '=' . $value, + 'Path=/', + 'HttpOnly', + 'SameSite=Strict', + ]; + + if ($this->secure) { + $parts[] = 'Secure'; + } + + if ($maxAge !== null) { + $parts[] = 'Max-Age=' . $maxAge; + } + + return implode('; ', $parts); + } +} diff --git a/backend/src/Auth/SessionStore.php b/backend/src/Auth/SessionStore.php new file mode 100644 index 0000000..999415a --- /dev/null +++ b/backend/src/Auth/SessionStore.php @@ -0,0 +1,145 @@ +sess: Sitzungsdaten, TTL = Leerlauffenster + * sess:user: Menge aller Sitzungen eines Benutzers, fuer Zwangsabmeldung + */ +final readonly class SessionStore +{ + public function __construct( + private Redis $redis, + private string $prefix, + private int $ttl, + ) {} + + public function create(User $user): Session + { + $now = time(); + $session = new Session( + id: self::newId(), + userId: $user->id, + username: $user->username, + displayName: $user->displayName, + role: $user->role, + issuedAt: $now, + lastSeenAt: $now, + ); + + $this->redis->setex($this->key($session->id), $this->ttl, json_encode($session->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + $this->redis->sAdd($this->userKey($user->id), $session->id); + // Der Index darf den laengsten moeglichen Sitzungslauf ueberdauern, nicht laenger. + $this->redis->expire($this->userKey($user->id), $this->ttl * 24); + + return $session; + } + + public function read(string $id): ?Session + { + if (!self::looksLikeId($id)) { + return null; + } + + $raw = $this->redis->get($this->key($id)); + + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? Session::fromArray($id, $decoded) : null; + } + + /** + * Gleitendes Leerlauffenster: jede authentifizierte Anfrage schiebt die TTL nach vorn. + * Der Rumpf wird nur einmal pro Minute neu geschrieben, sonst reicht ein EXPIRE. + */ + public function touch(Session $session): void + { + $now = time(); + + if ($now - $session->lastSeenAt < 60) { + $this->redis->expire($this->key($session->id), $this->ttl); + + return; + } + + $refreshed = new Session( + id: $session->id, + userId: $session->userId, + username: $session->username, + displayName: $session->displayName, + role: $session->role, + issuedAt: $session->issuedAt, + lastSeenAt: $now, + ); + + $this->redis->setex($this->key($session->id), $this->ttl, json_encode($refreshed->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + public function destroy(string $id): void + { + $session = $this->read($id); + + if ($session !== null) { + $this->redis->sRem($this->userKey($session->userId), $id); + } + + $this->redis->del($this->key($id)); + } + + /** + * Meldet einen Benutzer ueberall ab. Wird nach Rollenwechsel, Deaktivierung, + * Passwortwechsel und Loeschung aufgerufen, damit eine laufende Sitzung + * keine Rechte behaelt, die der Benutzer nicht mehr hat. + */ + public function destroyAllFor(string $userId): int + { + $ids = $this->redis->sMembers($this->userKey($userId)); + + if (!is_array($ids) || $ids === []) { + $this->redis->del($this->userKey($userId)); + + return 0; + } + + $this->redis->del(array_map(fn (string $id): string => $this->key($id), $ids)); + $this->redis->del($this->userKey($userId)); + + return count($ids); + } + + private static function newId(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } + + private static function looksLikeId(string $id): bool + { + return $id !== '' && strlen($id) <= 64 && preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1; + } + + private function key(string $id): string + { + return $this->prefix . 'sess:' . $id; + } + + private function userKey(string $userId): string + { + return $this->prefix . 'sess:user:' . $userId; + } +} diff --git a/backend/src/Bootstrap/Container.php b/backend/src/Bootstrap/Container.php new file mode 100644 index 0000000..1d6d3db --- /dev/null +++ b/backend/src/Bootstrap/Container.php @@ -0,0 +1,127 @@ +bool('APP_DEBUG', false)) { + $builder->enableCompilation($projectRoot . '/var/cache'); + } + + $builder->addDefinitions([ + Config::class => $config, + + // ── PostgreSQL ──────────────────────────────────────────────── + PDO::class => factory(static function (Config $config): PDO { + return new PDO( + $config->string('DB_DSN', 'pgsql:host=127.0.0.1;port=5432;dbname=ekdos'), + $config->string('DB_USER', 'ekdos'), + $config->string('DB_PASSWORD'), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], + ); + }), + + // ── Redis: Sitzungen und n8n-Cache ──────────────────────────── + Redis::class => factory(static function (Config $config): Redis { + $redis = new Redis(); + $redis->connect($config->string('REDIS_HOST', '127.0.0.1'), $config->int('REDIS_PORT', 6379), 2.0); + + if ($config->has('REDIS_PASSWORD')) { + $redis->auth($config->string('REDIS_PASSWORD')); + } + + $redis->select($config->int('REDIS_DB', 0)); + + return $redis; + }), + + SessionStore::class => factory(static function (Redis $redis, Config $config): SessionStore { + return new SessionStore($redis, $config->string('REDIS_PREFIX', 'ekdos:'), $config->int('SESSION_TTL', 3600)); + }), + + SessionCookie::class => factory(static function (Config $config): SessionCookie { + return new SessionCookie($config->string('SESSION_COOKIE', 'ekdos_session'), $config->bool('SESSION_COOKIE_SECURE', true)); + }), + + AuthService::class => factory(static function (UserRepository $users, SessionStore $sessions, Redis $redis, Config $config): AuthService { + return new AuthService( + users: $users, + sessions: $sessions, + redis: $redis, + prefix: $config->string('REDIS_PREFIX', 'ekdos:'), + maxAttempts: $config->int('LOGIN_MAX_ATTEMPTS', 10), + window: $config->int('LOGIN_WINDOW', 900), + ); + }), + + Cache::class => factory(static function (Redis $redis, Config $config): Cache { + return new Cache($redis, $config->string('REDIS_PREFIX', 'ekdos:')); + }), + + // ── n8n ─────────────────────────────────────────────────────── + Endpoints::class => factory(static fn (Config $config): Endpoints => Endpoints::fromConfig($config)), + + Guzzle::class => factory(static function (Config $config): Guzzle { + return new Guzzle([ + 'timeout' => $config->int('N8N_TIMEOUT', 15), + 'connect_timeout' => $config->int('N8N_CONNECT_TIMEOUT', 5), + // n8n antwortet gelegentlich mit einer Weiterleitung auf sich selbst. + 'allow_redirects' => ['max' => 3], + 'headers' => ['User-Agent' => 'EK-DOS-WEB/3.0'], + ]); + }), + + Client::class => autowire()->constructorParameter('http', get(Guzzle::class)), + + UserRepository::class => autowire(), + ]); + + return $builder->build(); + } + + /** Liest .env, faellt aber auf echte Umgebungsvariablen zurueck (Container, systemd). */ + public static function loadConfig(string $projectRoot): Config + { + $values = getenv(); + + if (is_readable($projectRoot . '/.env')) { + $dotenv = \Dotenv\Dotenv::createArrayBacked($projectRoot); + $values = $dotenv->load() + $values; + } + + return Config::fromEnvironment($values); + } +} diff --git a/backend/src/Bootstrap/Routes.php b/backend/src/Bootstrap/Routes.php new file mode 100644 index 0000000..86e68de --- /dev/null +++ b/backend/src/Bootstrap/Routes.php @@ -0,0 +1,127 @@ +group('/api', static function (RouteCollectorProxy $api): void { + // ── Anonym ──────────────────────────────────────────────────── + // Die Oberflaeche prueft vor der Anmeldung, ob das Backend lebt. + $api->get('/health', static fn (Request $r, Response $w): Response => Json::write($w, ['ok' => true, 'service' => 'ek-dos-web', 'time' => gmdate('c')])); + + $api->post('/auth/login', [AuthController::class, 'login']); + $api->get('/auth/me', [AuthController::class, 'me']); + $api->post('/auth/logout', [AuthController::class, 'logout']); + + // n8n meldet hierueber eine Ticketerstellung. Traegt entweder eine + // Sitzung oder das geteilte Geheimnis; die Pruefung liegt im Controller. + $api->post('/tickets/refresh', [TicketsController::class, 'refresh']); + + // ── Angemeldet ──────────────────────────────────────────────── + $api->group('', static function (RouteCollectorProxy $secure): void { + $secure->post('/auth/refresh', [AuthController::class, 'refresh']); + $secure->post('/auth/password', [AuthController::class, 'changeOwnPassword']); + + // Tickets + $secure->get('/tickets', [TicketsController::class, 'index']); + $secure->get('/tickets/digitale-akte', [TicketsController::class, 'digitalFile']); + $secure->get('/tickets/servicebericht', [TicketsController::class, 'serviceReport']); + $secure->post('/tickets/consultation', [TicketsController::class, 'consultation']) + ->add(new RequirePermission(Permission::InvoicesProcess)); + + // Angebote + $secure->get('/offers', [OffersController::class, 'index']); + $secure->post('/offers', [OffersController::class, 'update']); + $secure->delete('/offers', [OffersController::class, 'delete']) + ->add(new RequirePermission(Permission::OffersDelete)); + + // Kundenstamm + $secure->get('/customers', [CustomersController::class, 'index']); + $secure->post('/customers', [CustomersController::class, 'create']); + $secure->put('/customers', [CustomersController::class, 'update']); + $secure->delete('/customers', [CustomersController::class, 'delete']) + ->add(new RequirePermission(Permission::CustomersDelete)); + $secure->get('/customers/digitale-akte', [CustomersController::class, 'digitalFile']); + + // Rechnungen eines Kunden und die Gesamtuebersicht + $secure->get('/customer-invoices', [CustomerInvoicesController::class, 'index']); + $secure->post('/customer-invoices', [CustomerInvoicesController::class, 'assign']); + $secure->get('/customer-invoices/pdf', [CustomerInvoicesController::class, 'pdf']); + $secure->post('/customer-invoices/sync', [CustomerInvoicesController::class, 'sync']); + + // Rechnungsablauf: anfertigen, pruefen, versenden + $secure->get('/invoices-review', [InvoicesController::class, 'reviewList']); + $secure->group('', static function (RouteCollectorProxy $invoices): void { + $invoices->get('/invoices-create', [InvoicesController::class, 'createList']); + $invoices->put('/invoices-create', [InvoicesController::class, 'completeCreateTask']); + $invoices->get('/invoices-create/digitale-akte', [InvoicesController::class, 'digitalFile']); + $invoices->get('/invoices-send', [InvoicesController::class, 'sendList']); + $invoices->post('/invoices-send', [InvoicesController::class, 'confirmSent']); + })->add(new RequirePermission(Permission::InvoicesProcess)); + + // Interne Aufgaben + $secure->get('/internal-tasks', [InternalTasksController::class, 'index']); + $secure->post('/internal-tasks', [InternalTasksController::class, 'create']); + $secure->put('/internal-tasks', [InternalTasksController::class, 'complete']); + $secure->patch('/internal-tasks', [InternalTasksController::class, 'edit']); + $secure->delete('/internal-tasks', [InternalTasksController::class, 'delete']) + ->add(new RequirePermission(Permission::TasksDelete)); + + // Online-Kaeufe + $secure->get('/online-purchases', [OnlinePurchasesController::class, 'index']); + $secure->post('/online-purchases', [OnlinePurchasesController::class, 'create']) + ->add(new RequirePermission(Permission::PurchasesCreate)); + $secure->put('/online-purchases', [OnlinePurchasesController::class, 'complete']) + ->add(new RequirePermission(Permission::PurchasesComplete)); + $secure->delete('/online-purchases', [OnlinePurchasesController::class, 'delete']) + ->add(new RequirePermission(Permission::PurchasesDelete)); + + // Stundennachweise + $secure->get('/hours', [HoursController::class, 'index']); + + // Benutzerverwaltung + $secure->group('/users', static function (RouteCollectorProxy $users): void { + $users->get('', [UserController::class, 'index']); + $users->post('', [UserController::class, 'create']); + $users->get('/audit', [UserController::class, 'audit']); + $users->patch('/{id}', [UserController::class, 'update']); + $users->delete('/{id}', [UserController::class, 'delete']); + })->add(new RequirePermission(Permission::UsersManage)); + })->add(new RequireAuth()); + }); + + // Alles unterhalb von /api, was es nicht gibt, antwortet als JSON. + // Statische Dateien beantwortet nginx, sie erreichen PHP nie. + $app->any('/api/{path:.*}', static fn (Request $r, Response $w): Response => Json::error($w, 'Diese Schnittstelle gibt es nicht.', 404)); + } +} diff --git a/backend/src/Http/Middleware/ErrorHandler.php b/backend/src/Http/Middleware/ErrorHandler.php new file mode 100644 index 0000000..125b838 --- /dev/null +++ b/backend/src/Http/Middleware/ErrorHandler.php @@ -0,0 +1,59 @@ +handle($request); + } catch (HttpMethodNotAllowedException) { + return Json::error(new Psr7Response(), 'Diese Methode ist für diese Schnittstelle nicht vorgesehen.', 405); + } catch (HttpNotFoundException) { + // Kein Fehlerfall, sondern ein Tippfehler in der Adresse. Nicht protokollieren. + return Json::error(new Psr7Response(), 'Diese Schnittstelle gibt es nicht.', 404); + } catch (Throwable $exception) { + error_log(sprintf( + '[ekdos] %s %s -- %s: %s @ %s:%d', + $request->getMethod(), + (string) $request->getUri()->getPath(), + $exception::class, + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + )); + + // Sentry-Einstiegspunkt: \Sentry\captureException($exception); + + $message = $this->debug + ? $exception::class . ': ' . $exception->getMessage() + : 'Im Backend ist ein unerwarteter Fehler aufgetreten.'; + + return Json::error(new Psr7Response(), $message, 500); + } + } +} diff --git a/backend/src/Http/Middleware/RequireAuth.php b/backend/src/Http/Middleware/RequireAuth.php new file mode 100644 index 0000000..c11d79b --- /dev/null +++ b/backend/src/Http/Middleware/RequireAuth.php @@ -0,0 +1,29 @@ +handle($request); + } +} diff --git a/backend/src/Http/Middleware/RequirePermission.php b/backend/src/Http/Middleware/RequirePermission.php new file mode 100644 index 0000000..f6919b0 --- /dev/null +++ b/backend/src/Http/Middleware/RequirePermission.php @@ -0,0 +1,41 @@ +can($this->permission)) { + return Json::error( + new Psr7Response(), + 'Dafür fehlt die Berechtigung "' . Permission::label($this->permission) . '".', + 403, + ); + } + + return $handler->handle($request); + } +} diff --git a/backend/src/Http/Middleware/SessionMiddleware.php b/backend/src/Http/Middleware/SessionMiddleware.php new file mode 100644 index 0000000..51b4fa1 --- /dev/null +++ b/backend/src/Http/Middleware/SessionMiddleware.php @@ -0,0 +1,46 @@ +cookie->read($request); + $session = $id === '' ? null : $this->sessions->read($id); + + if ($session !== null) { + $this->sessions->touch($session); + } + + return $handler->handle($request->withAttribute(self::ATTRIBUTE, $session)); + } + + public static function of(Request $request): ?Session + { + $session = $request->getAttribute(self::ATTRIBUTE); + + return $session instanceof Session ? $session : null; + } +} diff --git a/backend/src/N8n/Cache.php b/backend/src/N8n/Cache.php new file mode 100644 index 0000000..43c3a1c --- /dev/null +++ b/backend/src/N8n/Cache.php @@ -0,0 +1,197 @@ +n8n:fresh: die kurzlebige Antwort + * n8n:stale: die Rueckfallkopie + * n8n:lock: Single-Flight-Sperre + * n8n:group: Menge aller Schluessel einer Gruppe + */ +final readonly class Cache +{ + /** Wie lange ein wartender Aufrufer auf das Ergebnis des Single-Flight hofft. */ + private const int WAIT_TOTAL_MS = 400; + private const int WAIT_STEP_MS = 25; + private const int LOCK_TTL_SECONDS = 15; + + public function __construct( + private Redis $redis, + private string $prefix, + ) {} + + /** + * @param string $group Gruppe fuer die gezielte Verwerfung, z. B. "tickets". + * @param string $key Eindeutig fuer diese Abfrage inklusive Parameter. + * @param int $ttl Sekunden, die die Antwort als frisch gilt. + * @param int $staleTtl Sekunden, die die Rueckfallkopie vorgehalten wird. + * @param callable():Reply $fetch + */ + public function remember(string $group, string $key, int $ttl, int $staleTtl, callable $fetch): Reply + { + $hash = sha1($key); + + if (($hit = $this->readEntry($this->freshKey($hash))) !== null) { + return $hit; + } + + if (!$this->acquireLock($hash)) { + // Jemand anderes holt gerade. Kurz auf dessen Ergebnis warten. + if (($shared = $this->waitForFresh($hash)) !== null) { + return $shared; + } + + // Warten hat nichts gebracht: lieber etwas Veraltetes als gar nichts. + if (($stale = $this->readEntry($this->staleKey($hash))) !== null) { + return $stale->asStale(); + } + } + + try { + $reply = $fetch(); + + if ($reply->ok()) { + $this->store($group, $hash, $reply, $ttl, $staleTtl); + + return $reply; + } + + // n8n antwortet, aber mit einem Fehler. Alte Daten schlagen eine Fehlerseite. + return $this->readEntry($this->staleKey($hash))?->asStale() ?? $reply; + } finally { + $this->releaseLock($hash); + } + } + + /** Verwirft alle Eintraege der genannten Gruppen. Nach jedem schreibenden Aufruf. */ + public function invalidate(string ...$groups): void + { + foreach ($groups as $group) { + $groupKey = $this->groupKey($group); + $members = $this->redis->sMembers($groupKey); + + if (is_array($members) && $members !== []) { + $this->redis->del($members); + } + + $this->redis->del($groupKey); + } + } + + /** Leert alles, was dieser Cache angelegt hat. Nur fuer die Wartungs-CLI. */ + public function flushAll(): int + { + $removed = 0; + $pattern = $this->prefix . 'n8n:*'; + $cursor = null; + + do { + $keys = $this->redis->scan($cursor, $pattern, 500); + + if (is_array($keys) && $keys !== []) { + $removed += (int) $this->redis->del($keys); + } + } while ($cursor > 0); + + return $removed; + } + + private function store(string $group, string $hash, Reply $reply, int $ttl, int $staleTtl): void + { + $payload = json_encode( + ['status' => $reply->status, 'body' => $reply->body, 'contentType' => $reply->contentType], + JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + + $this->redis->setex($this->freshKey($hash), max(1, $ttl), $payload); + $this->redis->setex($this->staleKey($hash), max($ttl, $staleTtl), $payload); + $this->redis->sAdd($this->groupKey($group), $this->freshKey($hash), $this->staleKey($hash)); + $this->redis->expire($this->groupKey($group), max($ttl, $staleTtl) + 60); + } + + private function readEntry(string $key): ?Reply + { + $raw = $this->redis->get($key); + + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + + if (!is_array($decoded) || !isset($decoded['status'], $decoded['body'])) { + return null; + } + + return new Reply((int) $decoded['status'], (string) $decoded['body'], (string) ($decoded['contentType'] ?? '')); + } + + private function waitForFresh(string $hash): ?Reply + { + $deadline = microtime(true) + self::WAIT_TOTAL_MS / 1000; + + while (microtime(true) < $deadline) { + usleep(self::WAIT_STEP_MS * 1000); + + if (($hit = $this->readEntry($this->freshKey($hash))) !== null) { + return $hit; + } + } + + return null; + } + + private function acquireLock(string $hash): bool + { + return (bool) $this->redis->set($this->lockKey($hash), '1', ['NX', 'EX' => self::LOCK_TTL_SECONDS]); + } + + private function releaseLock(string $hash): void + { + $this->redis->del($this->lockKey($hash)); + } + + private function freshKey(string $hash): string + { + return $this->prefix . 'n8n:fresh:' . $hash; + } + + private function staleKey(string $hash): string + { + return $this->prefix . 'n8n:stale:' . $hash; + } + + private function lockKey(string $hash): string + { + return $this->prefix . 'n8n:lock:' . $hash; + } + + private function groupKey(string $group): string + { + return $this->prefix . 'n8n:group:' . $group; + } +} diff --git a/backend/src/N8n/CachePolicy.php b/backend/src/N8n/CachePolicy.php new file mode 100644 index 0000000..4b384db --- /dev/null +++ b/backend/src/N8n/CachePolicy.php @@ -0,0 +1,41 @@ +send('GET', $url, $headers, null); + } + + public function post(string $url, ?array $json = null, array $headers = []): Reply + { + return $this->send('POST', $url, $headers, $json); + } + + public function put(string $url, ?array $json = null, array $headers = []): Reply + { + return $this->send('PUT', $url, $headers, $json); + } + + public function delete(string $url, ?array $json = null, array $headers = []): Reply + { + return $this->send('DELETE', $url, $headers, $json); + } + + private function send(string $method, string $url, array $headers, ?array $json): Reply + { + $options = ['headers' => $headers + ['Accept' => 'application/json'], 'http_errors' => false]; + + if ($json !== null) { + $options['json'] = $json; + } + + try { + $response = $this->http->request($method, $url, $options); + + return new Reply( + status: $response->getStatusCode(), + body: (string) $response->getBody(), + contentType: $response->getHeaderLine('Content-Type'), + ); + } catch (GuzzleException) { + // n8n nicht erreichbar. Kein Stacktrace ins Protokoll: der Aufrufer + // meldet das als 502 und faellt, wo moeglich, auf den Cache zurueck. + return new Reply(status: 0, body: '', contentType: ''); + } + } +} diff --git a/backend/src/N8n/Endpoints.php b/backend/src/N8n/Endpoints.php new file mode 100644 index 0000000..9e08a53 --- /dev/null +++ b/backend/src/N8n/Endpoints.php @@ -0,0 +1,164 @@ +string('N8N_PUBLIC_BASE', 'https://n8n.elektro-krueger.eu'), '/'), + internalHost: $config->string('N8N_INTERNAL_HOST', '10.0.11.131'), + ); + } + + /** Webhook am Hauptdienst (Port 5678, nur ueber den CNAME erreichbar). */ + public function webhook(string $path = '', array $query = []): string + { + return $this->publicBase . self::PREFIX . $path . self::query($query); + } + + /** Beliebiger weiterer n8n-Dienst im internen Netz. */ + public function internal(int $port, string $path, array $query = []): string + { + return 'http://' . $this->internalHost . ':' . $port . $path . self::query($query); + } + + // ── Tickets ─────────────────────────────────────────────────────────── + public function openTickets(): string + { + return $this->webhook('/offene-tickets'); + } + + public function ticketConsultation(): string + { + return $this->webhook('/offene-tickets/kundenruecksprache'); + } + + public function ticketDigitalFile(string $ticket): string + { + return $this->webhook('/tickets/digitale-akte', ['ticket' => $ticket]); + } + + public function ticketServiceReport(string $ticket): string + { + return $this->webhook('/tickets/servicebericht', ['ticket' => $ticket]); + } + + // ── Angebote ────────────────────────────────────────────────────────── + public function offers(string $action = ''): string + { + return $this->webhook('/angebote' . $action); + } + + // ── Kundenstamm ─────────────────────────────────────────────────────── + public function customers(): string + { + return $this->webhook('/kundenstamm'); + } + + public function customerDigitalFile(string $customer): string + { + return $this->webhook('/kundenstamm/digitale-akte', ['customer' => $customer]); + } + + // ── Rechnungen ──────────────────────────────────────────────────────── + public function invoices(array $query = []): string + { + return $this->webhook('/rechnungen', $query); + } + + public function allInvoices(): string + { + return $this->webhook('/rechnungen/alle'); + } + + public function assignInvoice(): string + { + return $this->webhook('/rechnungen/zuordnen'); + } + + public function invoicePdf(string $path): string + { + return $this->webhook('/rechnungen/pdf', ['path' => $path]); + } + + public function invoiceSync(): string + { + return $this->webhook('/rechnungen/sync'); + } + + public function invoicesCreate(): string + { + return $this->webhook('/rechnungen-anfertigen'); + } + + public function invoicesCreateComplete(): string + { + return $this->webhook('/rechnungen-anfertigen/erledigt'); + } + + public function invoicesReview(): string + { + return $this->webhook('/rechnungen-pruefen'); + } + + public function invoicesSend(): string + { + return $this->webhook('/rechnungen-versenden'); + } + + public function digitalFile(string $taskId): string + { + return $this->webhook('/digitale-akte', ['taskId' => $taskId]); + } + + // ── Interne Aufgaben ────────────────────────────────────────────────── + public function internalTasks(string $action = ''): string + { + return $this->webhook('/interne-aufgaben' . $action); + } + + // ── Online-Kaeufe ───────────────────────────────────────────────────── + public function onlinePurchases(string $action = ''): string + { + return $this->webhook('/online-kaeufe' . $action); + } + + // ── Stundennachweise ────────────────────────────────────────────────── + public function hours(): string + { + return $this->webhook('/stundennachweise'); + } + + private static function query(array $query): string + { + $filtered = array_filter($query, static fn (mixed $value): bool => $value !== null && $value !== ''); + + return $filtered === [] ? '' : '?' . http_build_query($filtered); + } +} diff --git a/backend/src/N8n/Reply.php b/backend/src/N8n/Reply.php new file mode 100644 index 0000000..2ee9b6f --- /dev/null +++ b/backend/src/N8n/Reply.php @@ -0,0 +1,45 @@ +status >= 200 && $this->status < 300; + } + + /** Status fuer die Oberflaeche: ein Verbindungsfehler wird zu 502. */ + public function statusOr(int $fallback = 502): int + { + return $this->status === 0 ? $fallback : $this->status; + } + + public function decoded(): array + { + return Json::decode($this->body); + } + + /** n8n liefert Listen mal als {"key":[...]}, mal als nacktes Array. */ + public function items(string $key): array + { + return Json::listFrom($this->decoded(), $key); + } + + public function asStale(): self + { + return new self($this->status, $this->body, $this->contentType, true); + } +} diff --git a/backend/src/Relay/CustomerInvoicesController.php b/backend/src/Relay/CustomerInvoicesController.php new file mode 100644 index 0000000..9906618 --- /dev/null +++ b/backend/src/Relay/CustomerInvoicesController.php @@ -0,0 +1,132 @@ +query($request, 'mode'); + $wantsAll = $mode === 'all'; + $secret = $this->invoiceSyncSecret(); + + if ($wantsAll && $secret === '') { + return Json::error($response, 'Die geschützte Rechnungsübersicht ist noch nicht eingerichtet.', 503); + } + + $url = $wantsAll ? $this->n8nUrl->allInvoices() : $this->n8nUrl->invoices([ + 'kunde' => $this->query($request, 'kunde'), + 'aliases' => $this->query($request, 'aliases'), + 'mode' => $mode, + 'liegenschaft' => $this->query($request, 'liegenschaft'), + ]); + + $reply = $this->cachedGet(CachePolicy::Invoices, $url, $wantsAll ? ['x-ekdos-invoice-sync' => $secret] : []); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Rechnungen konnten nicht geladen werden.'); + } + + $decoded = $reply->decoded(); + + return Json::write($response, [ + 'invoices' => is_array($decoded['invoices'] ?? null) ? array_values($decoded['invoices']) : [], + 'properties' => is_array($decoded['properties'] ?? null) ? array_values($decoded['properties']) : [], + 'stale' => $reply->stale ?: null, + ]); + } + + /** Einen offenen Beleg einem Kunden zuordnen. */ + public function assign(Request $request, Response $response): Response + { + $body = $this->body($request); + $id = self::text($body, 'id'); + $customer = self::text($body, 'kunde'); + $secret = $this->invoiceSyncSecret(); + + if ($id === '' || $customer === '') { + return Json::error($response, 'Rechnung und Kunde müssen ausgewählt sein.', 400); + } + + if ($secret === '') { + return Json::error($response, 'Die geschützte Rechnungszuordnung ist noch nicht eingerichtet.', 503); + } + + $reply = $this->n8n->post( + $this->n8nUrl->assignInvoice(), + ['id' => $id, 'kunde' => $customer, 'kundennummer' => self::text($body, 'kundennummer')], + ['x-ekdos-invoice-sync' => $secret], + ); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Rechnung konnte nicht zugeordnet werden.'); + } + + $this->cache->invalidate(CachePolicy::Invoices[0]); + + return Json::passthrough($response, $reply->body); + } + + /** + * Rechnungs-PDF. + * + * Der Pfad kommt aus der Oberflaeche, deshalb wird er streng geprueft: er muss + * unterhalb der Rechnungsablage liegen, auf .pdf enden und darf kein ".." + * enthalten. Ohne diese Pruefung waere die Route ein Dateibrowser fuer die NAS. + */ + public function pdf(Request $request, Response $response): Response + { + $path = $this->query($request, 'path'); + + if (!str_starts_with($path, self::PDF_ROOT) || !str_ends_with(strtolower($path), '.pdf') || str_contains($path, '..')) { + return Json::error($response, 'Ungültiger Rechnungspfad.', 400); + } + + $reply = $this->n8n->get($this->n8nUrl->invoicePdf($path)); + + if (!$reply->ok() || $reply->body === '') { + return $this->upstreamError($response, $reply, 'Die Rechnung ist nicht verfügbar.'); + } + + return $this->pdfResponse($response, $reply->body, 'Rechnung.pdf'); + } + + /** Stoesst den Abgleich der Rechnungsablage in n8n an. */ + public function sync(Request $request, Response $response): Response + { + $secret = $this->invoiceSyncSecret(); + + if ($secret === '') { + return Json::error($response, 'Der geschützte Rechnungsabgleich ist noch nicht eingerichtet.', 503); + } + + $reply = $this->n8n->post($this->n8nUrl->invoiceSync(), null, ['x-ekdos-invoice-sync' => $secret]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Rechnungsabgleich konnte nicht durchgeführt werden.'); + } + + // Nach einem Abgleich stimmt keine der Rechnungslisten mehr. + $this->cache->invalidate( + CachePolicy::Invoices[0], + CachePolicy::InvoicesCreate[0], + CachePolicy::InvoicesReview[0], + CachePolicy::InvoicesSend[0], + ); + + return $response->withStatus(204); + } +} diff --git a/backend/src/Relay/CustomersController.php b/backend/src/Relay/CustomersController.php new file mode 100644 index 0000000..d43de1c --- /dev/null +++ b/backend/src/Relay/CustomersController.php @@ -0,0 +1,101 @@ +cachedGet(CachePolicy::Customers, $this->n8nUrl->customers(), $this->readHeaders()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Kundenstamm konnte nicht aus n8n geladen werden.'); + } + + return $this->listResponse($response, $reply, 'customers', 'customers'); + } + + /** Neuanlage. n8n verlangt dafuer das geteilte Geheimnis, nicht den Lese-Schluessel. */ + public function create(Request $request, Response $response): Response + { + $secret = $this->invoiceSyncSecret(); + + if ($secret === '') { + return Json::error($response, 'Die sichere Kundenanlage ist noch nicht eingerichtet.', 503); + } + + $reply = $this->n8n->post($this->n8nUrl->customers(), $this->body($request), ['x-ekdos-invoice-sync' => $secret]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Customers[0]); + + return Json::write($response, ['customer' => $reply->decoded()], 201); + } + + public function update(Request $request, Response $response): Response + { + $reply = $this->n8n->put($this->n8nUrl->customers(), $this->body($request), $this->readHeaders()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Customers[0]); + + return Json::write($response, ['customer' => $reply->decoded()]); + } + + /** Vormals: nur Sascha. */ + public function delete(Request $request, Response $response): Response + { + $reply = $this->n8n->delete($this->n8nUrl->customers(), $this->body($request), $this->readHeaders()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.'); + } + + $this->cache->invalidate(CachePolicy::Customers[0]); + + return Json::write($response, ['deleted' => true]); + } + + public function digitalFile(Request $request, Response $response): Response + { + $customer = $this->query($request, 'customer'); + + if ($customer === '' || mb_strlen($customer) > 160) { + return Json::error($response, 'Der Kunde fehlt.', 400); + } + + $reply = $this->n8n->get($this->n8nUrl->customerDigitalFile($customer)); + + if (!$reply->ok() || $reply->body === '') { + return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.'); + } + + return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf'); + } + + /** + * Der Lese-Schluessel des Kundenstamm-Workflows. + * + * Er stand frueher als Literal im Quelltext; jetzt kommt er aus der Umgebung, + * damit er rotiert werden kann, ohne die Anwendung neu zu bauen. + */ + private function readHeaders(): array + { + $key = $this->config->string('N8N_CUSTOMER_KEY'); + + return $key === '' ? [] : ['X-EK-DOS-Customer-Key' => $key]; + } +} diff --git a/backend/src/Relay/HoursController.php b/backend/src/Relay/HoursController.php new file mode 100644 index 0000000..ea1d610 --- /dev/null +++ b/backend/src/Relay/HoursController.php @@ -0,0 +1,111 @@ +cachedGet(CachePolicy::Hours, $this->n8nUrl->hours()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Stundennachweise konnten nicht aus n8n geladen werden.'); + } + + $decoded = $reply->decoded(); + // n8n liefert den Report je nach Workflow-Zweig als Objekt oder als Liste mit einem Element. + $source = array_is_list($decoded) ? (is_array($decoded[0] ?? null) ? $decoded[0] : []) : $decoded; + + $employees = []; + + foreach ((is_array($source['employees'] ?? null) ? $source['employees'] : []) as $row) { + if (is_array($row) && ($employee = self::mapEmployee($row)) !== null) { + $employees[] = $employee; + } + } + + $payload = [ + 'month' => self::text($source, 'month'), + 'year' => self::number($source, 'year'), + 'generatedAt' => self::text($source, 'generatedAt') !== '' ? self::text($source, 'generatedAt') : gmdate('c'), + 'employees' => $employees, + 'statusPriority' => self::text($source, 'statusPriority') !== '' ? self::text($source, 'statusPriority') : null, + ]; + + if ($reply->stale) { + $payload['stale'] = true; + } + + return Json::write($response, $payload); + } + + /** @return array|null */ + private static function mapEmployee(array $row): ?array + { + $name = self::text($row, 'name'); + + if ($name === '') { + return null; + } + + $entries = []; + + foreach ((is_array($row['entries'] ?? null) ? $row['entries'] : []) as $entry) { + if (is_array($entry) && !array_is_list($entry)) { + $entries[] = self::mapEntry($entry); + } + } + + return [ + 'name' => $name, + 'totalHours' => self::number($row, 'totalHours'), + 'entries' => $entries, + 'workDaysYear' => self::number($row, 'workDaysYear'), + ]; + } + + /** @return array */ + private static function mapEntry(array $entry): array + { + $date = self::text($entry, 'date'); + $label = self::text($entry, 'label'); + $status = self::text($entry, 'status'); + + return [ + 'date' => $date, + 'label' => $label !== '' ? $label : $date, + 'status' => $status !== '' ? $status : 'Betrieb', + 'customer' => self::optional($entry, 'customer'), + 'property' => self::optional($entry, 'property'), + 'report' => self::optional($entry, 'report'), + 'hours' => self::number($entry, 'hours'), + ]; + } + + private static function number(array $source, string $key): float + { + $value = $source[$key] ?? null; + + return is_numeric($value) ? (float) $value : 0.0; + } + + private static function optional(array $source, string $key): ?string + { + $value = self::text($source, $key); + + return $value !== '' ? $value : null; + } +} diff --git a/backend/src/Relay/InternalTasksController.php b/backend/src/Relay/InternalTasksController.php new file mode 100644 index 0000000..d843bfe --- /dev/null +++ b/backend/src/Relay/InternalTasksController.php @@ -0,0 +1,147 @@ +cachedGet(CachePolicy::Tasks, $this->n8nUrl->internalTasks()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Interne Aufgaben konnten nicht geladen werden.'); + } + + return $this->listResponse($response, $reply, 'tasks', 'tasks'); + } + + public function create(Request $request, Response $response): Response + { + $body = $this->body($request); + $fields = self::validate($body); + + if (is_string($fields)) { + return Json::error($response, $fields, 400); + } + + $fields['erstellt_von'] = $this->session($request)->displayName; + $reply = $this->n8n->post($this->n8nUrl->internalTasks(), $fields); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Tasks[0]); + + return Json::passthrough($response, $reply->body, 201); + } + + public function complete(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Die Aufgabe fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->internalTasks('/erledigt'), [ + 'id' => $id, + 'erledigt_von' => $this->session($request)->displayName, + ]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht abgeschlossen werden.'); + } + + $this->cache->invalidate(CachePolicy::Tasks[0]); + + return Json::passthrough($response, $reply->body); + } + + public function edit(Request $request, Response $response): Response + { + $body = $this->body($request); + $id = self::text($body, 'id'); + $fields = self::validate($body); + + if ($id === '') { + return Json::error($response, 'Die Aufgabe fehlt.', 400); + } + + if (is_string($fields)) { + return Json::error($response, 'Bitte Aufgabe, Empfänger und Kategorie vollständig angeben.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->internalTasks('/bearbeiten'), ['id' => $id] + $fields); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht bearbeitet werden.'); + } + + $this->cache->invalidate(CachePolicy::Tasks[0]); + + return Json::passthrough($response, $reply->body); + } + + /** Vormals: nur Sascha. */ + public function delete(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Die Aufgabe fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->internalTasks('/loeschen'), ['id' => $id]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gelöscht werden.'); + } + + $this->cache->invalidate(CachePolicy::Tasks[0]); + + return Json::passthrough($response, $reply->body); + } + + /** + * @return array|string Felder oder die Fehlermeldung. + */ + private static function validate(array $body): array|string + { + $task = self::text($body, 'aufgabe'); + $recipient = self::text($body, 'empfaenger'); + $category = self::text($body, 'kategorie'); + + if ($task === '') { + return 'Bitte Aufgabe eingeben.'; + } + + if (!in_array($recipient, self::RECIPIENTS, true) || !in_array($category, self::CATEGORIES, true)) { + return 'Bitte Empfänger und Kategorie auswählen.'; + } + + return [ + 'aufgabe' => $task, + 'empfaenger' => $recipient, + 'kategorie' => $category, + 'kunden_id' => self::text($body, 'kunden_id'), + ]; + } +} diff --git a/backend/src/Relay/InvoicesController.php b/backend/src/Relay/InvoicesController.php new file mode 100644 index 0000000..2542b29 --- /dev/null +++ b/backend/src/Relay/InvoicesController.php @@ -0,0 +1,135 @@ +cachedGet(CachePolicy::InvoicesCreate, $this->n8nUrl->invoicesCreate()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Rechnungsaufgaben-Schnittstelle ist nicht erreichbar.'); + } + + return Json::passthrough($response, $reply->body); + } + + public function completeCreateTask(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->invoicesCreateComplete(), ['id' => $id]); + $decoded = $reply->decoded(); + + // n8n meldet einen fachlichen Fehler auch mit HTTP 200 und ok:false. + if (!$reply->ok() || ($decoded['ok'] ?? null) === false) { + $message = self::text($decoded, 'error'); + + return Json::error($response, $message !== '' ? $message : 'Die Rechnungsaufgabe konnte nicht abgeschlossen werden.', 502); + } + + $this->cache->invalidate(CachePolicy::InvoicesCreate[0], CachePolicy::InvoicesReview[0]); + + return Json::passthrough($response, $reply->body); + } + + public function reviewList(Request $request, Response $response): Response + { + $reply = $this->cachedGet(CachePolicy::InvoicesReview, $this->n8nUrl->invoicesReview()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Prüflisten-Schnittstelle ist nicht erreichbar.'); + } + + return Json::passthrough($response, $reply->body); + } + + public function sendList(Request $request, Response $response): Response + { + $reply = $this->cachedGet(CachePolicy::InvoicesSend, $this->n8nUrl->invoicesSend()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Versandaufgaben-Schnittstelle ist nicht erreichbar.'); + } + + return Json::passthrough($response, $reply->body); + } + + public function confirmSent(Request $request, Response $response): Response + { + $body = $this->body($request); + $id = self::text($body, 'id'); + + if ($id === '') { + return Json::error($response, 'Die Versandaufgabe fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->invoicesSend(), $body); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Versand konnte nicht bestätigt werden.'); + } + + $this->cache->invalidate(CachePolicy::InvoicesSend[0], CachePolicy::Invoices[0]); + + return Json::passthrough($response, $reply->body); + } + + /** + * Die Digitale Akte zu einer Rechnungsaufgabe. + * n8n liefert sie hier als base64 in einem JSON-Feld, nicht als Binaerrumpf. + */ + public function digitalFile(Request $request, Response $response): Response + { + $taskId = $this->query($request, 'taskId'); + + if ($taskId === '') { + return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400); + } + + $reply = $this->n8n->get($this->n8nUrl->digitalFile($taskId)); + $decoded = $reply->decoded(); + $contentBytes = self::text($decoded, 'contentBytes'); + + if (!$reply->ok() || $contentBytes === '') { + $message = self::text($decoded, 'error'); + + return Json::error( + $response, + $message !== '' ? $message : 'Die Digitale Akte ist nicht verfügbar (HTTP ' . $reply->statusOr(502) . ').', + 502, + ); + } + + $bytes = base64_decode($contentBytes, true); + + if ($bytes === false) { + return Json::error($response, 'Die Digitale Akte konnte nicht gelesen werden.', 502); + } + + $name = self::text($decoded, 'name'); + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name !== '' ? $name : 'Digitale_Akte.pdf') ?? 'Digitale_Akte.pdf'; + + return $this->pdfResponse($response, $bytes, $filename); + } +} diff --git a/backend/src/Relay/OffersController.php b/backend/src/Relay/OffersController.php new file mode 100644 index 0000000..3bafba9 --- /dev/null +++ b/backend/src/Relay/OffersController.php @@ -0,0 +1,122 @@ +cachedGet(CachePolicy::Offers, $this->n8nUrl->offers()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Angebote konnten nicht aus n8n geladen werden.'); + } + + return $this->listResponse($response, $reply, 'offers', 'offers'); + } + + /** Versandstatus, Beauftragung und Ruecksetzung laufen ueber dieselbe Route. */ + public function update(Request $request, Response $response): Response + { + $body = $this->body($request); + $id = self::text($body, 'id'); + $action = self::text($body, 'action'); + + if ($id === '') { + return Json::error($response, 'Das Angebot fehlt.', 400); + } + + return match ($action) { + 'versendet' => $this->markSent($response, $body, $id), + 'beauftragt' => $this->markCommissioned($response, $body, $id), + 'zuruecksetzen' => $this->reset($response, $id), + default => Json::error($response, 'Unbekannte Angebotsaktion.', 400), + }; + } + + /** Vormals: nur Sascha. */ + public function delete(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Das Angebot fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->offers('/loeschen'), ['id' => $id]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Das Angebot konnte nicht gelöscht werden.'); + } + + $this->cache->invalidate(CachePolicy::Offers[0]); + + return Json::passthrough($response, $reply->body); + } + + private function markSent(Response $response, array $body, string $id): Response + { + $shipping = self::text($body, 'versandart'); + + if ($shipping !== 'E-Mail' && $shipping !== 'Post') { + return Json::error($response, 'Bitte E-Mail oder Post auswählen.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->offers('/versendet'), ['id' => $id, 'versandart' => $shipping]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Offers[0]); + + return Json::passthrough($response, $reply->body); + } + + private function markCommissioned(Response $response, array $body, string $id): Response + { + $reply = $this->n8n->post($this->n8nUrl->offers('/beauftragt'), [ + 'id' => $id, + 'angebot_nummer' => self::text($body, 'angebot_nummer'), + 'kunde' => self::text($body, 'kunde'), + 'objekt_zusatz' => self::text($body, 'objekt_zusatz'), + ]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Beauftragung konnte nicht gespeichert werden.'); + } + + // Eine Beauftragung erzeugt in n8n eine Rechnungsaufgabe, deshalb faellt + // auch der Rechnungs-Cache. + $this->cache->invalidate(CachePolicy::Offers[0], CachePolicy::InvoicesCreate[0]); + + return Json::passthrough($response, $reply->body); + } + + /** Geschuetzte Ruecksetzung: n8n verlangt hier das geteilte Geheimnis. */ + private function reset(Response $response, string $id): Response + { + $secret = $this->invoiceSyncSecret(); + + if ($secret === '') { + return Json::error($response, 'Die geschützte Rücksetzung ist noch nicht eingerichtet.', 503); + } + + $reply = $this->n8n->post($this->n8nUrl->offers('/zuruecksetzen'), ['id' => $id], ['x-ekdos-invoice-sync' => $secret]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht zurückgesetzt werden.'); + } + + $this->cache->invalidate(CachePolicy::Offers[0]); + + return Json::passthrough($response, $reply->body); + } +} diff --git a/backend/src/Relay/OnlinePurchasesController.php b/backend/src/Relay/OnlinePurchasesController.php new file mode 100644 index 0000000..ff00a3c --- /dev/null +++ b/backend/src/Relay/OnlinePurchasesController.php @@ -0,0 +1,87 @@ +cachedGet(CachePolicy::Purchases, $this->n8nUrl->onlinePurchases()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Die Online-Käufe konnten nicht aus n8n geladen werden.'); + } + + return $this->listResponse($response, $reply, 'purchases', 'purchases'); + } + + /** Vormals: nur Sascha. */ + public function create(Request $request, Response $response): Response + { + $body = $this->body($request); + $article = self::text($body, 'artikel'); + $marketplace = self::text($body, 'kaufort'); + $date = self::text($body, 'kaufdatum'); + + if ($article === '' || $marketplace === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + return Json::error($response, 'Artikel, Marktplatz und ein gültiges Kaufdatum sind erforderlich.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->onlinePurchases(), ['artikel' => $article, 'kaufort' => $marketplace, 'kaufdatum' => $date]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht in n8n gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Purchases[0]); + + return Json::write($response, ['purchase' => $reply->decoded()], 201); + } + + /** Vormals: nur Svenja. */ + public function complete(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Die Kaufposition fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/erledigt'), ['id' => $id]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht abgeschlossen werden.'); + } + + $this->cache->invalidate(CachePolicy::Purchases[0]); + + return Json::write($response, $reply->decoded() + ['erledigt_am' => gmdate('c')]); + } + + /** Vormals: nur Sascha. */ + public function delete(Request $request, Response $response): Response + { + $id = self::text($this->body($request), 'id'); + + if ($id === '') { + return Json::error($response, 'Die Kaufposition fehlt.', 400); + } + + $reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/loeschen'), ['id' => $id]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht gelöscht werden.'); + } + + $this->cache->invalidate(CachePolicy::Purchases[0]); + + return Json::passthrough($response, $reply->body); + } +} diff --git a/backend/src/Relay/RelayController.php b/backend/src/Relay/RelayController.php new file mode 100644 index 0000000..ec24f30 --- /dev/null +++ b/backend/src/Relay/RelayController.php @@ -0,0 +1,120 @@ +cache->remember( + $group, + $url . '|' . json_encode($headers, JSON_THROW_ON_ERROR), + $ttl, + $stale, + fn (): Reply => $this->n8n->get($url, $headers), + ); + } + + protected function session(Request $request): Session + { + // RequireAuth laeuft vor jedem Controller, deshalb ist die Sitzung hier gesetzt. + return SessionMiddleware::of($request) ?? throw new \LogicException('Route ohne RequireAuth erreicht.'); + } + + protected function body(Request $request): array + { + $parsed = $request->getParsedBody(); + + if (is_array($parsed)) { + return $parsed; + } + + return Json::decode((string) $request->getBody()); + } + + protected function query(Request $request, string $key, string $fallback = ''): string + { + $value = $request->getQueryParams()[$key] ?? null; + + return is_string($value) ? trim($value) : $fallback; + } + + /** Nimmt Zeichenketten und Zahlen an und liefert immer eine getrimmte Zeichenkette. */ + protected static function text(array $source, string $key): string + { + $value = $source[$key] ?? null; + + return is_string($value) || is_int($value) || is_float($value) ? trim((string) $value) : ''; + } + + /** + * Einheitliche Fehlerantwort fuer einen fehlgeschlagenen n8n-Aufruf. + * Ein Verbindungsfehler (Status 0) wird zu 502. + */ + protected function upstreamError(Response $response, Reply $reply, string $message): Response + { + return Json::error($response, $message, $reply->statusOr(502)); + } + + /** Antwortet mit einer Liste und markiert, wenn sie aus dem Rueckfall-Cache stammt. */ + protected function listResponse(Response $response, Reply $reply, string $key, string $as): Response + { + $payload = [$as => $reply->items($key), 'refreshedAt' => gmdate('c')]; + + if ($reply->stale) { + $payload['stale'] = true; + $payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Daten.'; + } + + return Json::write($response, $payload); + } + + /** Reicht eine Binaerantwort (PDF) unveraendert durch. */ + protected function pdfResponse(Response $response, string $bytes, string $filename): Response + { + $response->getBody()->write($bytes); + + return $response->withHeader('Content-Type', 'application/pdf') + ->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"') + ->withHeader('Cache-Control', 'no-store'); + } + + /** Geteiltes Geheimnis fuer die geschuetzten n8n-Workflows. */ + protected function invoiceSyncSecret(): string + { + return $this->config->string('N8N_INVOICE_SYNC_SECRET'); + } +} diff --git a/backend/src/Relay/TicketsController.php b/backend/src/Relay/TicketsController.php new file mode 100644 index 0000000..dfbb927 --- /dev/null +++ b/backend/src/Relay/TicketsController.php @@ -0,0 +1,217 @@ +cachedGet(CachePolicy::Tickets, $this->n8nUrl->openTickets()); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Offene Tickets konnten nicht aus n8n geladen werden.'); + } + + $tickets = []; + + foreach ($reply->items('tickets') as $row) { + if (!is_array($row)) { + continue; + } + + $ticket = self::mapTicket($row); + + // Zeilen ohne Ticketnummer sind fuer die Ansicht wertlos. + if ($ticket['id'] !== '') { + $tickets[] = $ticket; + } + } + + $payload = ['tickets' => $tickets, 'refreshedAt' => gmdate('c')]; + + if ($reply->stale) { + $payload['stale'] = true; + $payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Tickets.'; + } + + return Json::write($response, $payload); + } + + /** + * Wird von Workflow4A nach einer Ticketerstellung aufgerufen. + * + * Anders als frueher ist das kein Platzhalter mehr: der Aufruf verwirft den + * Ticket-Cache, sodass die naechste Abfrage der Oberflaeche garantiert die + * neuen Daten sieht statt auf den TTL-Ablauf zu warten. + */ + public function refresh(Request $request, Response $response): Response + { + $secret = $this->config->string('N8N_REFRESH_SECRET'); + + // Entweder eine angemeldete Sitzung oder das geteilte Geheimnis aus n8n. + if (SessionMiddleware::of($request) === null) { + $presented = $request->getHeaderLine('x-ekdos-webhook-secret'); + + if ($secret === '' || !hash_equals($secret, $presented)) { + return Json::error($response, 'Bitte erneut anmelden.', 401); + } + } + + $this->cache->invalidate(CachePolicy::Tickets[0]); + + return Json::write($response, ['ok' => true, 'refreshedAt' => gmdate('c')]); + } + + /** Ergebnis der Kundenruecksprache festhalten. Vormals: nur Svenja. */ + public function consultation(Request $request, Response $response): Response + { + $body = $this->body($request); + $ticketNumber = self::text($body, 'ticketnummer'); + $result = self::text($body, 'ergebnis'); + + if ($ticketNumber === '' || $result === '') { + return Json::error($response, 'Ticketnummer und Ergebnis sind erforderlich.', 400); + } + + if (mb_strlen($result) > 4000) { + return Json::error($response, 'Das Ergebnis darf maximal 4.000 Zeichen enthalten.', 400); + } + + // Vorher pruefen, ob schon ein Ergebnis hinterlegt ist. Bewusst ungecacht: + // hier zaehlt der aktuelle Stand, nicht ein 15 Sekunden alter. + $lookup = $this->n8n->get($this->n8nUrl->openTickets()); + + foreach ($lookup->items('tickets') as $row) { + if (!is_array($row)) { + continue; + } + + $id = self::text($row, 'ticketnummer') !== '' ? self::text($row, 'ticketnummer') : self::text($row, 'id'); + + if ($id === $ticketNumber && self::text($row, 'kundenruecksprache_ergebnis') !== '') { + return Json::error($response, 'Für dieses Ticket wurde bereits ein Ergebnis gespeichert.', 409); + } + } + + $reply = $this->n8n->post($this->n8nUrl->ticketConsultation(), ['ticketnummer' => $ticketNumber, 'ergebnis' => $result]); + + if (!$reply->ok()) { + return $this->upstreamError($response, $reply, 'Das Ergebnis der Kundenrücksprache konnte nicht in n8n gespeichert werden.'); + } + + $this->cache->invalidate(CachePolicy::Tickets[0]); + + return Json::write($response, ['ok' => true, 'updatedAt' => gmdate('c')]); + } + + public function digitalFile(Request $request, Response $response): Response + { + $ticket = $this->query($request, 'ticket'); + + if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) { + return Json::error($response, 'Die Ticketnummer ist ungültig.', 400); + } + + $reply = $this->n8n->get($this->n8nUrl->ticketDigitalFile($ticket)); + + if (!$reply->ok() || $reply->body === '') { + return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.'); + } + + return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf'); + } + + public function serviceReport(Request $request, Response $response): Response + { + $ticket = $this->query($request, 'ticket'); + + if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) { + return Json::error($response, 'Die Ticketnummer ist ungültig.', 400); + } + + $reply = $this->n8n->get($this->n8nUrl->ticketServiceReport($ticket)); + + if (!$reply->ok() || $reply->body === '') { + return $this->upstreamError($response, $reply, 'Der letzte Servicebericht ist nicht verfügbar.'); + } + + return $this->pdfResponse($response, $reply->body, 'Letzter_Servicebericht.pdf'); + } + + /** @return array */ + private static function mapTicket(array $row): array + { + $continuation = self::text($row, 'fortsetzung'); + $decisionCode = self::text($row, 'entscheidung_code'); + + return [ + 'id' => self::firstOf($row, ['ticketnummer', 'id'], ''), + 'status' => self::firstOf($row, ['status'], 'offen'), + 'reportStatus' => self::optional($row, 'letzter_servicebericht_status'), + 'customer' => self::firstOf($row, ['kunde', 'customer'], '–'), + 'property' => self::firstOf($row, ['liegenschaft', 'property'], '–'), + 'title' => self::firstOf($row, ['title'], ''), + 'updatedAt' => self::firstOf($row, ['updatedAt', 'updated_at', 'erstellt_am', 'createdAt'], ''), + 'continuation' => $continuation !== '' ? $continuation : null, + 'decisionCode' => $decisionCode !== '' ? $decisionCode : null, + 'requiresCustomerConsultation' => self::needsConsultation($decisionCode, $continuation), + 'consultationResult' => self::optional($row, 'kundenruecksprache_ergebnis'), + 'consultationUpdatedAt' => self::optional($row, 'kundenruecksprache_am'), + 'consultationBy' => self::optional($row, 'kundenruecksprache_von'), + ]; + } + + /** + * Ein Ticket braucht Ruecksprache, wenn n8n es entweder ausdruecklich so + * kennzeichnet oder der Fortsetzungstext des Technikers es so formuliert. + */ + private static function needsConsultation(string $decisionCode, string $continuation): bool + { + if ($decisionCode === 'TEILERLEDIGUNG_RUECKSPRACHE_KUNDE') { + return true; + } + + // Der Text kommt aus einem PDF und traegt gelegentlich zusammengesetzte + // Umlaute, deshalb vor dem Vergleich nach NFKC normalisieren. + $normalized = class_exists(\Normalizer::class) + ? (\Normalizer::normalize($continuation, \Normalizer::FORM_KC) ?: $continuation) + : $continuation; + + return preg_match('/nimmt\s+mit\s+kunden\s+für\s+das\s+weitere\s+vorgehen\s+kontakt\s+auf/iu', $normalized) === 1; + } + + /** Erstes gesetztes Feld aus der Liste, sonst der Vorgabewert. */ + private static function firstOf(array $row, array $keys, string $fallback): string + { + foreach ($keys as $key) { + if (isset($row[$key]) && (is_string($row[$key]) || is_numeric($row[$key]))) { + return (string) $row[$key]; + } + } + + return $fallback; + } + + /** Leere Felder werden zu null, damit die Oberflaeche sie ueberspringen kann. */ + private static function optional(array $row, string $key): ?string + { + $value = self::text($row, $key); + + return $value !== '' ? $value : null; + } +} diff --git a/backend/src/Support/Config.php b/backend/src/Support/Config.php new file mode 100644 index 0000000..2c0f259 --- /dev/null +++ b/backend/src/Support/Config.php @@ -0,0 +1,51 @@ +values[$key] ?? null; + + return is_string($value) && trim($value) !== '' ? trim($value) : $fallback; + } + + public function int(string $key, int $fallback): int + { + $value = $this->values[$key] ?? null; + + return is_numeric($value) ? (int) $value : $fallback; + } + + public function bool(string $key, bool $fallback): bool + { + $value = $this->values[$key] ?? null; + + if (is_bool($value)) { + return $value; + } + + return match (is_string($value) ? strtolower(trim($value)) : null) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off' => false, + default => $fallback, + }; + } + + /** true, sobald ein Wert (z. B. ein geteiltes Geheimnis) hinterlegt ist. */ + public function has(string $key): bool + { + return $this->string($key) !== ''; + } +} diff --git a/backend/src/Support/Json.php b/backend/src/Support/Json.php new file mode 100644 index 0000000..f50e4bc --- /dev/null +++ b/backend/src/Support/Json.php @@ -0,0 +1,57 @@ +getBody()->write($body); + + return $response->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Cache-Control', 'no-store') + ->withStatus($status); + } + + public static function error(Response $response, string $message, int $status): Response + { + return self::write($response, ['error' => $message], $status); + } + + /** Schreibt einen bereits von n8n gelieferten JSON-Rumpf unveraendert durch. */ + public static function passthrough(Response $response, string $body, int $status = 200): Response + { + $response->getBody()->write($body === '' ? '{}' : $body); + + return $response->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Cache-Control', 'no-store') + ->withStatus($status); + } + + /** Dekodiert einen n8n-Rumpf defensiv: kaputtes JSON wird zu einem leeren Array. */ + public static function decode(string $body): array + { + $decoded = json_decode($body, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * n8n liefert Listen mal als {"tickets":[...]}, mal als nacktes Array. + * Diese Helferin nimmt beide Formen an. + */ + public static function listFrom(array $decoded, string $key): array + { + if (isset($decoded[$key]) && is_array($decoded[$key])) { + return array_values($decoded[$key]); + } + + return array_is_list($decoded) ? $decoded : []; + } +} diff --git a/backend/src/Users/Permission.php b/backend/src/Users/Permission.php new file mode 100644 index 0000000..0ed2689 --- /dev/null +++ b/backend/src/Users/Permission.php @@ -0,0 +1,54 @@ + */ + public const array ALL = [ + self::UsersManage, + self::OffersDelete, + self::CustomersDelete, + self::TasksDelete, + self::PurchasesCreate, + self::PurchasesDelete, + self::PurchasesComplete, + self::InvoicesProcess, + ]; + + /** Menschenlesbare Bezeichnung für die Benutzerverwaltung. */ + public static function label(string $permission): string + { + return match ($permission) { + self::UsersManage => 'Benutzerverwaltung', + self::OffersDelete => 'Angebote löschen', + self::CustomersDelete => 'Kunden löschen', + self::TasksDelete => 'Aufgaben löschen', + self::PurchasesCreate => 'Online-Käufe eintragen', + self::PurchasesDelete => 'Online-Käufe löschen', + self::PurchasesComplete => 'Online-Käufe abschließen', + self::InvoicesProcess => 'Rechnungen bearbeiten und versenden', + default => $permission, + }; + } +} diff --git a/backend/src/Users/Role.php b/backend/src/Users/Role.php new file mode 100644 index 0000000..d0a25f0 --- /dev/null +++ b/backend/src/Users/Role.php @@ -0,0 +1,49 @@ + 'Administration', + self::Inhaber => 'Inhaber', + self::Buero => 'Büro', + }; + } + + /** @return list */ + public function permissions(): array + { + return match ($this) { + self::Admin => Permission::ALL, + self::Inhaber => [ + Permission::OffersDelete, + Permission::CustomersDelete, + Permission::TasksDelete, + Permission::PurchasesCreate, + Permission::PurchasesDelete, + ], + self::Buero => [ + Permission::PurchasesComplete, + Permission::InvoicesProcess, + ], + }; + } + + public function allows(string $permission): bool + { + return in_array($permission, $this->permissions(), true); + } +} diff --git a/backend/src/Users/User.php b/backend/src/Users/User.php new file mode 100644 index 0000000..3b1589d --- /dev/null +++ b/backend/src/Users/User.php @@ -0,0 +1,54 @@ + $this->id, + 'username' => $this->username, + 'displayName' => $this->displayName, + 'role' => $this->role->value, + 'roleLabel' => $this->role->label(), + 'isActive' => $this->isActive, + 'createdAt' => $this->createdAt, + 'lastLoginAt' => $this->lastLoginAt, + 'permissions' => $this->role->permissions(), + ]; + } + + public function can(string $permission): bool + { + return $this->role->allows($permission); + } +} diff --git a/backend/src/Users/UserController.php b/backend/src/Users/UserController.php new file mode 100644 index 0000000..41a5a1c --- /dev/null +++ b/backend/src/Users/UserController.php @@ -0,0 +1,237 @@ + array_map(static fn (User $user): array => $user->toArray(), $this->users->all()), + 'roles' => self::roleCatalogue(), + ]); + } + + public function create(Request $request, Response $response): Response + { + $actor = SessionMiddleware::of($request); + $body = self::body($request); + + $username = trim((string) ($body['username'] ?? '')); + $displayName = trim((string) ($body['displayName'] ?? '')); + $password = (string) ($body['password'] ?? ''); + $role = Role::tryFrom((string) ($body['role'] ?? '')); + + if (($problem = self::validateName($username, $displayName)) !== null) { + return Json::error($response, $problem, 400); + } + + if ($role === null) { + return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400); + } + + if (($problem = AuthService::rejectWeakPassword($password)) !== null) { + return Json::error($response, $problem, 400); + } + + if ($this->users->usernameTaken($username)) { + return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409); + } + + $user = $this->users->create($username, $displayName, $role, AuthService::hash($password)); + $this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.create', $user->id, $user->username, ['role' => $role->value]); + + return Json::write($response, ['user' => $user->toArray()], 201); + } + + public function update(Request $request, Response $response, array $args): Response + { + $actor = SessionMiddleware::of($request); + $id = (string) ($args['id'] ?? ''); + $user = $this->users->find($id); + + if ($user === null) { + return Json::error($response, 'Dieser Benutzer existiert nicht.', 404); + } + + $body = self::body($request); + $fields = []; + $changed = []; + + if (array_key_exists('displayName', $body)) { + $displayName = trim((string) $body['displayName']); + + if ($displayName === '' || mb_strlen($displayName) > 120) { + return Json::error($response, 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.', 400); + } + + $fields['display_name'] = $displayName; + $changed[] = 'Anzeigename'; + } + + if (array_key_exists('username', $body)) { + $username = trim((string) $body['username']); + + if (($problem = self::validateName($username, 'x')) !== null) { + return Json::error($response, $problem, 400); + } + + if ($this->users->usernameTaken($username, $user->id)) { + return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409); + } + + $fields['username'] = $username; + $changed[] = 'Benutzername'; + } + + if (array_key_exists('role', $body)) { + $role = Role::tryFrom((string) $body['role']); + + if ($role === null) { + return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400); + } + + if ($actor?->userId === $user->id && $role !== $user->role) { + return Json::error($response, 'Die eigene Rolle kann nicht geändert werden.', 409); + } + + if ($user->role === Role::Admin && $role !== Role::Admin && $this->users->countAdmins($user->id) === 0) { + return Json::error($response, 'Es muss mindestens ein aktiver Administrator bestehen bleiben.', 409); + } + + $fields['role'] = $role->value; + $changed[] = 'Rolle'; + } + + if (array_key_exists('isActive', $body)) { + $isActive = (bool) $body['isActive']; + + if (!$isActive && $user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) { + return Json::error($response, 'Der letzte aktive Administrator kann nicht deaktiviert werden.', 409); + } + + if (!$isActive && $actor?->userId === $user->id) { + return Json::error($response, 'Das eigene Konto kann nicht deaktiviert werden.', 409); + } + + $fields['is_active'] = $isActive; + $changed[] = $isActive ? 'aktiviert' : 'deaktiviert'; + } + + if (array_key_exists('password', $body) && (string) $body['password'] !== '') { + $password = (string) $body['password']; + + if (($problem = AuthService::rejectWeakPassword($password)) !== null) { + return Json::error($response, $problem, 400); + } + + $fields['password_hash'] = AuthService::hash($password); + $changed[] = 'Passwort'; + } + + if ($fields === []) { + return Json::error($response, 'Es wurde nichts geändert.', 400); + } + + $updated = $this->users->update($user->id, $fields); + $this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.update', $user->id, $user->username, ['changed' => $changed]); + + // Rolle, Aktivierung oder Passwort geaendert: laufende Sitzungen beenden. + $forcesLogout = isset($fields['role']) || isset($fields['is_active']) || isset($fields['password_hash']); + $endedSessions = $forcesLogout ? $this->sessions->destroyAllFor($user->id) : 0; + + return Json::write($response, [ + 'user' => $updated?->toArray(), + 'endedSessions' => $endedSessions, + ]); + } + + public function delete(Request $request, Response $response, array $args): Response + { + $actor = SessionMiddleware::of($request); + $id = (string) ($args['id'] ?? ''); + $user = $this->users->find($id); + + if ($user === null) { + return Json::error($response, 'Dieser Benutzer existiert nicht.', 404); + } + + if ($actor?->userId === $user->id) { + return Json::error($response, 'Das eigene Konto kann nicht gelöscht werden.', 409); + } + + if ($user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) { + return Json::error($response, 'Der letzte aktive Administrator kann nicht gelöscht werden.', 409); + } + + // Erst abmelden, dann loeschen: sonst bliebe eine offene Sitzung ohne Konto zurueck. + $this->sessions->destroyAllFor($user->id); + $this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.delete', null, $user->username, ['role' => $user->role->value]); + $this->users->delete($user->id); + + return Json::write($response, ['deleted' => true]); + } + + /** Das Protokoll der Benutzerverwaltung, jüngste Einträge zuerst. */ + public function audit(Request $request, Response $response): Response + { + return Json::write($response, ['entries' => $this->users->recentAudit(50)]); + } + + /** @return list> */ + private static function roleCatalogue(): array + { + return array_map(static fn (Role $role): array => [ + 'value' => $role->value, + 'label' => $role->label(), + 'permissions' => array_map(Permission::label(...), $role->permissions()), + ], Role::cases()); + } + + private static function validateName(string $username, string $displayName): ?string + { + if (preg_match('/^[a-z0-9._-]{3,40}$/i', $username) !== 1) { + return 'Der Benutzername darf 3 bis 40 Zeichen lang sein und nur Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.'; + } + + if ($displayName === '' || mb_strlen($displayName) > 120) { + return 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.'; + } + + return null; + } + + private static function body(Request $request): array + { + $parsed = $request->getParsedBody(); + + return is_array($parsed) ? $parsed : Json::decode((string) $request->getBody()); + } +} diff --git a/backend/src/Users/UserRepository.php b/backend/src/Users/UserRepository.php new file mode 100644 index 0000000..f2a4d43 --- /dev/null +++ b/backend/src/Users/UserRepository.php @@ -0,0 +1,150 @@ + */ + public function all(): array + { + $rows = $this->db->query('select ' . self::COLUMNS . ' from users order by lower(display_name)')->fetchAll(); + + return array_map(User::fromRow(...), $rows); + } + + public function find(string $id): ?User + { + $statement = $this->db->prepare('select ' . self::COLUMNS . ' from users where id = :id'); + $statement->execute(['id' => $id]); + $row = $statement->fetch(); + + return $row === false ? null : User::fromRow($row); + } + + /** Liefert den Hash mit. Wird ausschliesslich fuer die Anmeldung verwendet. */ + public function findByUsernameWithHash(string $username): ?User + { + $statement = $this->db->prepare('select ' . self::COLUMNS . ', password_hash from users where lower(username) = lower(:username)'); + $statement->execute(['username' => $username]); + $row = $statement->fetch(); + + return $row === false ? null : User::fromRow($row); + } + + public function usernameTaken(string $username, ?string $exceptId = null): bool + { + $statement = $this->db->prepare('select 1 from users where lower(username) = lower(:username) and (cast(:except as uuid) is null or id <> cast(:except as uuid))'); + $statement->execute(['username' => $username, 'except' => $exceptId]); + + return $statement->fetchColumn() !== false; + } + + /** Zaehlt aktive Administratoren, optional ohne einen bestimmten Benutzer. */ + public function countAdmins(?string $exceptId = null): int + { + $statement = $this->db->prepare("select count(*) from users where role = 'admin' and is_active and (cast(:except as uuid) is null or id <> cast(:except as uuid))"); + $statement->execute(['except' => $exceptId]); + + return (int) $statement->fetchColumn(); + } + + public function create(string $username, string $displayName, Role $role, string $passwordHash, bool $isActive = true): User + { + $statement = $this->db->prepare( + 'insert into users (username, display_name, role, password_hash, is_active) + values (:username, :display_name, :role, :password_hash, :is_active) + returning ' . self::COLUMNS + ); + $statement->execute([ + 'username' => $username, + 'display_name' => $displayName, + 'role' => $role->value, + 'password_hash' => $passwordHash, + 'is_active' => $isActive ? 't' : 'f', + ]); + + return User::fromRow($statement->fetch()); + } + + /** Nur uebergebene Felder werden geschrieben, alles andere bleibt unangetastet. */ + public function update(string $id, array $fields): ?User + { + $allowed = ['username', 'display_name', 'role', 'password_hash', 'is_active']; + $sets = []; + $parameters = ['id' => $id]; + + foreach ($fields as $column => $value) { + if (!in_array($column, $allowed, true)) { + continue; + } + + $sets[] = $column . ' = :' . $column; + $parameters[$column] = is_bool($value) ? ($value ? 't' : 'f') : $value; + } + + if ($sets === []) { + return $this->find($id); + } + + $sets[] = 'updated_at = now()'; + $statement = $this->db->prepare('update users set ' . implode(', ', $sets) . ' where id = :id returning ' . self::COLUMNS); + $statement->execute($parameters); + $row = $statement->fetch(); + + return $row === false ? null : User::fromRow($row); + } + + public function delete(string $id): bool + { + $statement = $this->db->prepare('delete from users where id = :id'); + $statement->execute(['id' => $id]); + + return $statement->rowCount() > 0; + } + + public function touchLogin(string $id): void + { + $statement = $this->db->prepare('update users set last_login_at = now() where id = :id'); + $statement->execute(['id' => $id]); + } + + public function audit(?string $actorId, string $actorName, string $action, ?string $subjectId, string $subject, array $detail = []): void + { + $statement = $this->db->prepare( + 'insert into user_audit (actor_id, actor_name, action, subject_id, subject, detail) + values (cast(:actor_id as uuid), :actor_name, :action, cast(:subject_id as uuid), :subject, cast(:detail as jsonb))' + ); + $statement->execute([ + 'actor_id' => $actorId, + 'actor_name' => $actorName, + 'action' => $action, + 'subject_id' => $subjectId, + 'subject' => $subject, + 'detail' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]); + } + + /** @return list> */ + public function recentAudit(int $limit = 50): array + { + $statement = $this->db->prepare('select actor_name, action, subject, detail, created_at from user_audit order by created_at desc limit :limit'); + $statement->bindValue('limit', $limit, PDO::PARAM_INT); + $statement->execute(); + + return array_map(static fn (array $row): array => [ + 'actor' => $row['actor_name'], + 'action' => $row['action'], + 'subject' => $row['subject'], + 'detail' => json_decode((string) $row['detail'], true) ?: [], + 'createdAt' => $row['created_at'], + ], $statement->fetchAll()); + } +} diff --git a/deploy/nginx/ekdos.conf b/deploy/nginx/ekdos.conf new file mode 100644 index 0000000..6db62f7 --- /dev/null +++ b/deploy/nginx/ekdos.conf @@ -0,0 +1,97 @@ +# EK-DOS-WEB +# +# nginx macht beides: es liefert das statisch gebaute React-Bündel aus und +# reicht ausschliesslich /api/ an php-fpm weiter. Auf dem Server läuft kein +# Node und kein Anwendungsserver -- nur nginx und php8.5-fpm. +# +# Ablage: /etc/nginx/sites-available/ekdos.conf -> sites-enabled/ +# Prüfen: nginx -t && systemctl reload nginx + +upstream ekdos_php { + # Unix-Socket statt TCP: kein Netzwerkstack, keine offene Portnummer. + server unix:/run/php/ekdos.sock; +} + +server { + listen 80; + listen [::]:80; + server_name schulung.elektro-krueger.local; + + # Hinter einem TLS-Terminator (Cloudflare, vorgelagertes nginx) diesen Block + # entfernen und stattdessen den 443-Block unten verwenden. + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name schulung.elektro-krueger.local; + + ssl_certificate /etc/ssl/ekdos/fullchain.pem; + ssl_certificate_key /etc/ssl/ekdos/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # Das gebaute Bündel aus dem Gitea-Lauf. + root /var/www/ekdos/current; + index index.html; + + # Die Anwendung ist intern; sie gehört in keinen Suchindex. + add_header X-Robots-Tag "noindex, nofollow" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "same-origin" always; + + # Rechnungen und Serviceberichte sind mehrere Megabyte gross. + client_max_body_size 32m; + + # ── Statische Oberfläche ────────────────────────────────────────────── + # Vite vergibt Inhaltshashes, deshalb dürfen die Bündel dauerhaft im Cache + # liegen. Nur index.html darf das nicht, sonst sieht niemand ein Update. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-store"; + } + + # Einseitenanwendung: jede unbekannte Adresse bekommt index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # ── Backend ─────────────────────────────────────────────────────────── + location /api/ { + include fastcgi_params; + fastcgi_pass ekdos_php; + + # Einziger Einstiegspunkt. Es gibt keine weiteren .php-Dateien im Web. + fastcgi_param SCRIPT_FILENAME /var/www/ekdos/backend/public/index.php; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param DOCUMENT_ROOT /var/www/ekdos/backend/public; + + # Der Rechnungsabgleich und grosse PDFs brauchen Luft. + fastcgi_read_timeout 60s; + fastcgi_buffering off; + + # Damit die Anmeldebremse die echte Adresse sieht. + fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; + fastcgi_param HTTPS on; + } + + # PHP wird nirgendwo sonst ausgeführt. + location ~ \.php$ { + return 404; + } + + # Punktdateien bleiben unerreichbar (.env, .git und Ähnliches). + location ~ /\. { + deny all; + } + + access_log /var/log/nginx/ekdos.access.log; + error_log /var/log/nginx/ekdos.error.log warn; +} diff --git a/deploy/php-fpm/ekdos.pool.conf b/deploy/php-fpm/ekdos.pool.conf new file mode 100644 index 0000000..35ccd65 --- /dev/null +++ b/deploy/php-fpm/ekdos.pool.conf @@ -0,0 +1,49 @@ +; EK-DOS-WEB: eigener php-fpm-Pool. +; +; Ablage: /etc/php/8.5/fpm/pool.d/ekdos.conf +; Prüfen: php-fpm8.5 -t && systemctl restart php8.5-fpm +; +; Ein eigener Pool statt des mitgelieferten www-Pools: eigener Socket, eigener +; Benutzer, eigene Grenzen. Ein anderer Dienst auf demselben Server kann EK-DOS +; damit weder aushungern noch dessen Umgebung mitlesen. + +[ekdos] + +user = ekdos +group = ekdos + +listen = /run/php/ekdos.sock +listen.owner = www-data +listen.group = www-data +listen.mode = 0660 + +; Zwei Personen im Büro, dazu die 20-Sekunden-Abfrage der Ticketliste. +; ondemand hält keine ungenutzten Prozesse vor. +pm = ondemand +pm.max_children = 12 +pm.process_idle_timeout = 30s +pm.max_requests = 500 + +; Aufrufe an n8n haben ihr eigenes Zeitlimit (N8N_TIMEOUT). Dieses hier ist die +; Notbremse darüber, damit ein hängender Aufruf keinen Prozess dauerhaft bindet. +request_terminate_timeout = 60s + +; Fehler gehen in den Fehlerkanal, nicht in die Antwort. +php_admin_value[display_errors] = Off +php_admin_flag[log_errors] = On +php_admin_value[error_log] = /var/log/php/ekdos-error.log +php_admin_value[memory_limit] = 256M +php_admin_value[upload_max_filesize] = 32M +php_admin_value[post_max_size] = 32M + +; Das Backend liest und schreibt nur in seinem eigenen Verzeichnis. +php_admin_value[open_basedir] = /var/www/ekdos/backend:/tmp + +; Es werden keine PHP-Sitzungen verwendet -- die Sitzung liegt in Redis. +php_admin_value[session.save_handler] = files +php_admin_flag[session.auto_start] = Off + +; Opcache: der Quelltext ändert sich nur beim Ausrollen. +php_admin_flag[opcache.enable] = On +php_admin_value[opcache.validate_timestamps] = 0 +php_admin_value[opcache.max_accelerated_files] = 10000 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..64f04a3 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,146 @@ +# EK-DOS-WEB betreiben + +Zielsystem: Debian/Ubuntu mit nginx und php8.5-fpm. Postgres und Redis laufen +bereits für n8n und werden mitbenutzt (eigene Datenbank, eigene Redis-Nummer). + +## 1. Pakete + +```bash +sudo apt update +sudo apt install -y nginx php8.5-fpm php8.5-cli php8.5-pgsql php8.5-redis \ + php8.5-mbstring php8.5-curl php8.5-intl php8.5-xml +``` + +`php8.5-intl` ist optional, verbessert aber die Erkennung der Kundenrücksprache +in Ticketberichten (Unicode-Normalisierung). Ohne die Erweiterung greift ein +Rückfall ohne Normalisierung. + +## 2. Datenbank und Redis + +```bash +sudo -u postgres createuser ekdos --pwprompt +sudo -u postgres createdb ekdos --owner=ekdos +``` + +Redis braucht nichts weiter -- EK-DOS legt seine Schlüssel unter dem Präfix +`ekdos:` ab und nutzt eine eigene Datenbanknummer (`REDIS_DB`). + +> Redis hält Sitzungen **und** den n8n-Cache. Ist Redis weg, ist niemand mehr +> angemeldet und jede Ansicht fragt wieder direkt bei n8n an. Die Anwendung +> läuft weiter, aber langsamer und mit erneuter Anmeldung. + +## 3. Verzeichnisse + +```bash +sudo mkdir -p /var/www/ekdos/{releases,backend} +sudo useradd --system --home /var/www/ekdos --shell /usr/sbin/nologin ekdos +sudo chown -R ekdos:ekdos /var/www/ekdos +sudo mkdir -p /var/log/php && sudo chown ekdos:ekdos /var/log/php +``` + +## 4. Backend einrichten + +```bash +cd /var/www/ekdos/backend +sudo -u ekdos cp .env.example .env +sudo -u ekdos editor .env +sudo -u ekdos composer install --no-dev --optimize-autoloader +sudo -u ekdos php bin/ekdos migrate +sudo -u ekdos php bin/ekdos user:create # erster Administrator +sudo -u ekdos php bin/ekdos check +``` + +`.env` gehört dem Benutzer `ekdos` und sollte `chmod 600` sein. Sie wird beim +Ausrollen ausdrücklich nicht überschrieben. + +### n8n-Adressen + +| Einstellung | Wert | warum | +| ------------------- | --------------------------------- | ----------------------------------------------------------- | +| `N8N_PUBLIC_BASE` | `https://n8n.elektro-krueger.eu` | Der Dienst auf Port 5678 ist nur über den CNAME erreichbar | +| `N8N_INTERNAL_HOST` | `10.0.11.131` | Jeder weitere n8n-Dienst auf einem anderen Port | + +Alle heutigen `ek-dos-web`-Webhooks laufen über den 5678-Dienst und damit über +`N8N_PUBLIC_BASE`. Kommt später ein zweiter n8n-Dienst auf eigenem Port dazu, +wird er mit `Endpoints::internal(, '')` angesprochen und geht über +die RFC1918-Adresse -- er ist nach aussen nicht veröffentlicht. + +## 5. php-fpm + +```bash +sudo cp deploy/php-fpm/ekdos.pool.conf /etc/php/8.5/fpm/pool.d/ekdos.conf +sudo php-fpm8.5 -t +sudo systemctl restart php8.5-fpm +ls -l /run/php/ekdos.sock # muss www-data:www-data 0660 gehören +``` + +Der Pool setzt `opcache.validate_timestamps = 0`. Neuer Quelltext wird deshalb +**erst nach einem `systemctl reload php8.5-fpm`** wirksam -- die Ausrollstrecke +macht das selbst. + +## 6. nginx + +```bash +sudo cp deploy/nginx/ekdos.conf /etc/nginx/sites-available/ekdos.conf +sudo ln -sf /etc/nginx/sites-available/ekdos.conf /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +Kurzfassung: `fastcgi_pass unix:/run/php/ekdos.sock` für `/api/`, alles andere +`try_files $uri $uri/ /index.html`. Kein Anwendungsserver, kein Reverse Proxy +auf einen Node-Prozess. + +Läuft der Zugriff nur intern über HTTP, kann `SESSION_COOKIE_SECURE=false` +gesetzt und der 443-Block entfernt werden. Sobald TLS anliegt: wieder auf `true`. + +## 7. Ausrollen + +Die Gitea-Strecke (`.gitea/workflows/build.yml`) macht das bei jedem Push auf +`main`. Nötig sind: + +- ein SSH-Schlüssel als Gitea-Secret `DEPLOY_SSH_KEY` +- ein Konto `deploy` auf dem Server, das nach `/var/www/ekdos` schreiben darf +- `sudo systemctl reload php8.5-fpm` ohne Passwort für dieses Konto + +``` +deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.5-fpm +``` + +Jede Veröffentlichung landet unter `releases/`; sichtbar wird sie erst +durch den Symlink-Tausch von `current`. Die letzten fünf bleiben liegen, ein +Rückschritt ist damit ein `ln -sfn`. + +### Von Hand + +```bash +cd frontend && pnpm install --frozen-lockfile && pnpm build +rsync -az --delete frontend/dist/ server:/var/www/ekdos/releases/manuell/ +ssh server 'cd /var/www/ekdos && ln -sfn releases/manuell current && sudo systemctl reload php8.5-fpm' +``` + +## Prüfen, wenn etwas klemmt + +```bash +php backend/bin/ekdos check # Postgres, Redis, n8n +curl -sS https:///api/health # ohne Anmeldung erreichbar +tail -f /var/log/php/ekdos-error.log # Ausnahmen aus dem Backend +tail -f /var/log/nginx/ekdos.error.log +``` + +| Symptom | meistens | +| ------------------------------------------- | --------------------------------------------------------------------- | +| 502 auf `/api/` | Socket fehlt oder falsche Rechte; `systemctl status php8.5-fpm` | +| Anmeldung wirkt, danach sofort abgemeldet | Redis nicht erreichbar, oder `SESSION_COOKIE_SECURE=true` ohne TLS | +| Listen bleiben leer, Meldung „nicht erreichbar" | `N8N_PUBLIC_BASE` falsch, oder der Webhook ist in n8n nicht aktiv | +| Angezeigte Daten sind veraltet | Rückfall-Cache greift, weil n8n gerade nicht antwortet | +| 503 „noch nicht eingerichtet" | `N8N_INVOICE_SYNC_SECRET` bzw. `N8N_CUSTOMER_KEY` fehlt in `.env` | +| Quelltextänderung wirkt nicht | Opcache; `systemctl reload php8.5-fpm` | + +## Alle Benutzer aussperren + +```bash +redis-cli --scan --pattern 'ekdos:sess:*' | xargs -r redis-cli del +``` + +Danach muss sich jeder neu anmelden. Einzelne Konten trifft es gezielt über die +Benutzerverwaltung (Deaktivieren beendet die offenen Sitzungen sofort). diff --git a/docs/N8N.md b/docs/N8N.md new file mode 100644 index 0000000..ced2990 --- /dev/null +++ b/docs/N8N.md @@ -0,0 +1,109 @@ +# n8n-Schnittstelle + +Alle Fachdaten von EK-DOS-WEB liegen in n8n. Das Backend hält davon nichts vor +ausser einem kurzlebigen Cache. + +## Adressen + +Der n8n-Hauptdienst auf **Port 5678** ist ausschliesslich über +`https://n8n.elektro-krueger.eu` erreichbar. Jeder weitere n8n-Dienst auf einem +anderen Port ist nicht veröffentlicht und wird über **10.0.11.131** angesprochen. + +In `backend/src/N8n/Endpoints.php`: + +```php +$url->webhook('/offene-tickets'); // https://n8n.elektro-krueger.eu/webhook/ek-dos-web/offene-tickets +$url->internal(5679, '/webhook/irgendwas'); // http://10.0.11.131:5679/webhook/irgendwas +``` + +Neue Adressen gehören in diese Klasse, nicht verstreut in die Controller. + +## Endpunkte + +Alle unter `/webhook/ek-dos-web`. Die Spalte *Cache* nennt Gruppe sowie frische +und Rückfall-Haltbarkeit in Sekunden. + +| Pfad | Verwendung | Cache | +| ----------------------------------- | -------------------------------- | ------------------------ | +| `/offene-tickets` | Ticketliste | `tickets` 15 / 600 | +| `/offene-tickets/kundenruecksprache`| Ergebnis speichern | schreibend | +| `/tickets/digitale-akte` | PDF | – | +| `/tickets/servicebericht` | PDF | – | +| `/angebote` | Angebotsliste | `offers` 30 / 600 | +| `/angebote/versendet` `/beauftragt` `/zuruecksetzen` `/loeschen` | Aktionen | schreibend | +| `/kundenstamm` | Kundenliste, Anlage, Änderung | `customers` 120 / 1800 | +| `/kundenstamm/digitale-akte` | PDF | – | +| `/rechnungen` | Rechnungen eines Kunden | `invoices` 30 / 600 | +| `/rechnungen/alle` | Gesamtübersicht (geschützt) | `invoices` 30 / 600 | +| `/rechnungen/zuordnen` `/sync` | Zuordnung, Abgleich (geschützt) | schreibend | +| `/rechnungen/pdf` | PDF | – | +| `/rechnungen-anfertigen` | Aufgabenliste | `invoices-create` 30/600 | +| `/rechnungen-anfertigen/erledigt` | Aufgabe abschließen | schreibend | +| `/rechnungen-pruefen` | Prüfliste | `invoices-review` 30/600 | +| `/rechnungen-versenden` | Versandliste und -bestätigung | `invoices-send` 30/600 | +| `/digitale-akte` | PDF als base64 im JSON | – | +| `/interne-aufgaben` (+ `/erledigt` `/bearbeiten` `/loeschen`) | Aufgaben | `tasks` 20 / 600 | +| `/online-kaeufe` (+ `/erledigt` `/loeschen`) | Online-Käufe | `purchases` 30 / 600 | +| `/stundennachweise` | Monatsreport | `hours` 300 / 3600 | + +## Geteilte Geheimnisse + +| Kopfzeile | aus | wofür | +| -------------------------- | ------------------------- | ------------------------------------------------------------ | +| `x-ekdos-invoice-sync` | `N8N_INVOICE_SYNC_SECRET` | Rechnungsübersicht, Zuordnung, Abgleich, Kundenanlage, Angebot zurücksetzen | +| `X-EK-DOS-Customer-Key` | `N8N_CUSTOMER_KEY` | Lesen und Ändern im Kundenstamm | +| `x-ekdos-webhook-secret` | `N8N_REFRESH_SECRET` | n8n meldet EK-DOS eine Ticketänderung | + +Fehlt eines davon, antworten die betroffenen Routen mit **503** und einer +Meldung, die genau das sagt -- sie schlagen nicht stumm fehl. + +> Der Kundenschlüssel stand in der Next.js-Fassung als Literal im Quelltext +> (`app/api/customers/route.ts`). Er liegt jetzt in `.env`. **Er sollte in n8n +> gewechselt werden**, weil der alte Wert in der Versionsgeschichte steht. + +## n8n meldet eine Ticketänderung + +Der Workflow *Datenübergabe an EK-DOS-WEB* darf weiter auf seinen Endpunkt +zeigen, nur ohne Port: + +``` +POST https://schulung.elektro-krueger.local/api/tickets/refresh +x-ekdos-webhook-secret: +``` + +Anders als früher ist das kein Platzhalter mehr. In der Next.js-Fassung +antwortete die Route nur `{ok:true}` und tat sonst nichts; die Oberfläche sah +neue Tickets rein zufällig beim nächsten 20-Sekunden-Takt. Jetzt **verwirft der +Aufruf den Ticket-Cache**, sodass die nächste Abfrage die neuen Daten garantiert +sieht. + +Ohne gesetztes `N8N_REFRESH_SECRET` verlangt die Route eine angemeldete Sitzung +und weist den Aufruf aus n8n mit 401 ab. + +## Feldabbildung Tickets + +Die einzige Stelle, an der das Backend n8n-Felder umbenennt +(`backend/src/Relay/TicketsController.php`): + +| n8n | Oberfläche | +| -------------------------------- | ------------------------------ | +| `ticketnummer` / `id` | `id` | +| `kunde` / `customer` | `customer` | +| `liegenschaft` / `property` | `property` | +| `letzter_servicebericht_status` | `reportStatus` | +| `fortsetzung` | `continuation` | +| `entscheidung_code` | `decisionCode` | +| `kundenruecksprache_ergebnis` | `consultationResult` | +| `kundenruecksprache_am` / `_von` | `consultationUpdatedAt` / `By` | + +`requiresCustomerConsultation` wird abgeleitet: entweder +`entscheidung_code === "TEILERLEDIGUNG_RUECKSPRACHE_KUNDE"`, oder der +Fortsetzungstext enthält „nimmt mit Kunden für das weitere Vorgehen Kontakt auf". +Zeilen ohne Ticketnummer werden verworfen. + +## Bekannte Kopplung + +Interne Aufgaben kennen in n8n genau zwei Empfänger: `sascha` und `svenja`. Ein +drittes EK-DOS-Konto kann sich anmelden und alle Ansichten nutzen, aber noch +keine Aufgaben zugewiesen bekommen. Dafür müsste der n8n-Workflow *Interne +Aufgaben* einen freien Empfängerschlüssel annehmen. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2ebcd06 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + EK-DOS + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aefb3a6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "ek-dos-web-frontend", + "displayName": "EK-DOS WEB", + "version": "3.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.16.1", + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "19.2.6", + "react-dom": "19.2.6" + }, + "devDependencies": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.2", + "eslint": "9.39.4", + "typescript": "5.9.3", + "vite": "8.0.13" + } +} diff --git a/frontend/public/ek-dos-favicon.svg b/frontend/public/ek-dos-favicon.svg new file mode 100644 index 0000000..dfd703b --- /dev/null +++ b/frontend/public/ek-dos-favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/elektro-krueger-logo.png b/frontend/public/elektro-krueger-logo.png new file mode 100644 index 0000000..70f7cf9 Binary files /dev/null and b/frontend/public/elektro-krueger-logo.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..dfd703b --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..16fe3d3 --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..c7215fe --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/helga-profile.png b/frontend/public/helga-profile.png new file mode 100644 index 0000000..1d6fde4 Binary files /dev/null and b/frontend/public/helga-profile.png differ diff --git a/frontend/public/hero-ek-dos.png b/frontend/public/hero-ek-dos.png new file mode 100644 index 0000000..349fc93 Binary files /dev/null and b/frontend/public/hero-ek-dos.png differ diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..d05e7a1 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2fc8558 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { AuthProvider, useAuth } from '@/auth/AuthProvider'; +import { LoadingScreen, LoginScreen } from '@/auth/LoginScreen'; +import Dashboard from '@/views/Dashboard'; + +/** + * Der Auth-Gate. + * + * Drei Zustände, mehr gibt es nicht: es ist noch nicht entschieden, niemand ist + * angemeldet, oder jemand ist angemeldet. Erst im dritten Fall wird überhaupt + * eine Fachansicht gerendert -- so kann keine Ansicht in einen Zustand ohne + * Benutzer geraten. + */ +function Gate() { + const { user, ready } = useAuth(); + + if (!ready) return ; + if (!user) return ; + + return ; +} + +export function App() { + return ( + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ed7b71c --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,123 @@ +import { config } from '@/config'; + +/** + * Der einzige Weg nach draussen. + * + * `credentials: 'include'` sorgt dafuer, dass das Sitzungscookie mitgeht. Es gibt + * bewusst keinen Authorization-Header und kein Token im Browser: die Sitzung + * liegt vollstaendig im Backend, der Browser haelt nur die undurchsichtige Kennung. + */ + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'ApiError'; + } + + /** Die Sitzung ist abgelaufen oder wurde beendet. */ + get isUnauthorized(): boolean { + return this.status === 401; + } + + /** Angemeldet, aber ohne das noetige Recht. */ + get isForbidden(): boolean { + return this.status === 403; + } +} + +type RequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + signal?: AbortSignal; +}; + +/** Wird ausgeloest, sobald das Backend 401 meldet, damit die Anmeldemaske erscheint. */ +type UnauthorizedListener = () => void; +const unauthorizedListeners = new Set(); + +export function onUnauthorized(listener: UnauthorizedListener): () => void { + unauthorizedListeners.add(listener); + return () => unauthorizedListeners.delete(listener); +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, signal } = options; + + const response = await fetch(`${config.api.baseUrl}${path}`, { + method, + credentials: 'include', + cache: 'no-store', + signal, + headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }).catch(() => { + throw new ApiError('Das Backend ist nicht erreichbar.', 0); + }); + + if (response.status === 204) return undefined as T; + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + // 401 auf der Auth-Sonde ist eine Antwort, kein Fehler: dort steht, + // dass niemand angemeldet ist. Ueberall sonst heisst es, dass die + // Sitzung gerade abgelaufen ist. + if (response.status === 401 && path !== '/api/auth/me') { + unauthorizedListeners.forEach((listener) => listener()); + } + + const message = typeof payload?.error === 'string' ? payload.error : `Das Backend antwortet mit HTTP ${response.status}.`; + throw new ApiError(message, response.status); + } + + return payload as T; +} + +export const api = { + get: (path: string, signal?: AbortSignal) => request(path, { signal }), + post: (path: string, body?: unknown) => request(path, { method: 'POST', body }), + put: (path: string, body?: unknown) => request(path, { method: 'PUT', body }), + patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body }), + delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), +}; + +/** Adresse fuer eine PDF-Anzeige im Browser. Die Sitzung traegt ueber das Cookie. */ +export function fileUrl(path: string): string { + return `${config.api.baseUrl}${path}`; +} + +/** + * Übergangsbrücke für Dashboard.tsx. + * + * Die große Dashboard-Komponente ruft noch direkt `fetch("/api/…")` auf, geht + * also nicht durch `api` oben. Ohne diesen Aufsatz bliebe eine abgelaufene + * Sitzung dort unbemerkt und die Ansichten zeigten Fehlermeldungen, statt zur + * Anmeldung zurückzufallen. + * + * Diese Funktion darf ersatzlos entfallen, sobald alle Ansichten den Client + * oben verwenden. + */ +let intercepted = false; + +export function watchLegacyFetchForExpiry(): void { + if (intercepted) return; + intercepted = true; + + const original = window.fetch.bind(window); + + window.fetch = async (input, init) => { + const response = await original(input, init); + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); + + // Nur eigene API-Aufrufe, und die Auth-Sonde bleibt außen vor: dort ist + // 401 die reguläre Antwort für "niemand angemeldet". + if (response.status === 401 && url.includes('/api/') && !url.includes('/api/auth/me')) { + unauthorizedListeners.forEach((listener) => listener()); + } + + return response; + }; +} diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..643bad5 --- /dev/null +++ b/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,117 @@ +import { api, onUnauthorized, watchLegacyFetchForExpiry } from '@/api/client'; +import { config } from '@/config'; +import type { CurrentUser, PermissionName } from '@/auth/types'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; + +type AuthState = { + user: CurrentUser | null; + /** Solange false, ist noch nicht entschieden, ob jemand angemeldet ist. */ + ready: boolean; + signIn: (username: string, password: string) => Promise; + signOut: () => Promise; + can: (permission: PermissionName) => boolean; +}; + +const AuthContext = createContext(null); + +/** + * Der Auth-Gate der Oberflaeche. + * + * Es wird nichts geraten: beim Start fragt die Anwendung /api/auth/me. 200 + * bedeutet angemeldet, 401 bedeutet Anmeldemaske. Danach halten zwei Uhren die + * Sitzung im Griff -- eine verlaengert sie, solange jemand arbeitet, die andere + * meldet nach einer Stunde ohne Eingabe ab. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + const lastActivity = useRef(Date.now()); + + const probe = useCallback(async () => { + try { + const data = await api.get<{ user: CurrentUser | null }>('/api/auth/me'); + setUser(data.user); + } catch { + // 401 und "Backend nicht erreichbar" fuehren beide zur Anmeldemaske. + setUser(null); + } finally { + setReady(true); + } + }, []); + + useEffect(() => { + void probe(); + }, [probe]); + + // Meldet das Backend irgendwo 401, faellt die Oberflaeche sofort zurueck. + // Der Aufsatz erfasst dabei auch die direkten fetch-Aufrufe in Dashboard.tsx. + useEffect(() => { + watchLegacyFetchForExpiry(); + + return onUnauthorized(() => setUser(null)); + }, []); + + // Jede Eingabe zaehlt als Aktivitaet. + useEffect(() => { + const note = () => { + lastActivity.current = Date.now(); + }; + + const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const; + events.forEach((event) => window.addEventListener(event, note, { passive: true })); + + return () => events.forEach((event) => window.removeEventListener(event, note)); + }, []); + + // Sitzung verlaengern, solange gearbeitet wird; sonst abmelden. + useEffect(() => { + if (!user) return; + + const timer = window.setInterval(() => { + if (Date.now() - lastActivity.current > config.session.idleMs) { + void api.post('/api/auth/logout').catch(() => undefined); + setUser(null); + return; + } + + void api + .post<{ user: CurrentUser | null }>('/api/auth/refresh') + .then((data) => setUser(data.user)) + .catch(() => setUser(null)); + }, config.session.refreshMs); + + return () => window.clearInterval(timer); + }, [user]); + + const signIn = useCallback(async (username: string, password: string) => { + const data = await api.post<{ user: CurrentUser }>('/api/auth/login', { username, password }); + lastActivity.current = Date.now(); + setUser(data.user); + }, []); + + const signOut = useCallback(async () => { + await api.post('/api/auth/logout').catch(() => undefined); + setUser(null); + }, []); + + const value = useMemo( + () => ({ + user, + ready, + signIn, + signOut, + can: (permission) => user?.permissions.includes(permission) ?? false, + }), + [user, ready, signIn, signOut], + ); + + return {children}; +} + +export function useAuth(): AuthState { + const context = useContext(AuthContext); + + if (context === null) throw new Error('useAuth benötigt einen AuthProvider.'); + + return context; +} diff --git a/frontend/src/auth/LoginScreen.tsx b/frontend/src/auth/LoginScreen.tsx new file mode 100644 index 0000000..d3401a7 --- /dev/null +++ b/frontend/src/auth/LoginScreen.tsx @@ -0,0 +1,88 @@ +import { useAuth } from '@/auth/AuthProvider'; +import { useState, type FormEvent } from 'react'; + +/** + * Anmeldemaske. + * + * Die beiden fest verdrahteten Schaltflaechen "Svenja" und "Sascha" sind einem + * Benutzernamensfeld gewichen -- Konten kommen jetzt aus der eigenen Datenbank + * und werden in der Benutzerverwaltung gepflegt. Das uebrige Gestaltungsbild + * bleibt unveraendert. + */ +export function LoginScreen() { + const { signIn } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(''); + + try { + await signIn(username.trim(), password); + } catch (problem) { + setError(problem instanceof Error ? problem.message : 'Die Anmeldung ist fehlgeschlagen.'); + setPassword(''); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ EK + + Elektro Krüger + Wir setzen Sie unter Strom + +
+

EK-DOS

+

Willkommen

+

Bitte melde dich an, um die digitale Schaltzentrale zu öffnen.

+
+ + + {error &&

{error}

} + +
+
+
+ +
+
+ ); +} + +export function LoadingScreen() { + return ( +
+
EK-DOS wird geladen …
+
+ ); +} diff --git a/frontend/src/auth/types.ts b/frontend/src/auth/types.ts new file mode 100644 index 0000000..0311dfc --- /dev/null +++ b/frontend/src/auth/types.ts @@ -0,0 +1,44 @@ +export type Role = 'admin' | 'inhaber' | 'buero'; + +/** Genau die Rechte, die das Backend in Permission.php fuehrt. */ +export const Permission = { + UsersManage: 'users.manage', + OffersDelete: 'offers.delete', + CustomersDelete: 'customers.delete', + TasksDelete: 'tasks.delete', + PurchasesCreate: 'purchases.create', + PurchasesDelete: 'purchases.delete', + PurchasesComplete: 'purchases.complete', + InvoicesProcess: 'invoices.process', +} as const; + +export type PermissionName = (typeof Permission)[keyof typeof Permission]; + +export type CurrentUser = { + id: string; + username: string; + /** Anzeigename, z. B. "Svenja". Die Oberflaeche zeigt ausschliesslich diesen. */ + name: string; + role: Role; + roleLabel: string; + permissions: PermissionName[]; + canManageUsers: boolean; +}; + +export type ManagedUser = { + id: string; + username: string; + displayName: string; + role: Role; + roleLabel: string; + isActive: boolean; + createdAt: string; + lastLoginAt: string | null; + permissions: PermissionName[]; +}; + +export type RoleOption = { + value: Role; + label: string; + permissions: string[]; +}; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts new file mode 100644 index 0000000..c9bc708 --- /dev/null +++ b/frontend/src/config/index.ts @@ -0,0 +1,33 @@ +/** + * Baukonfiguration. + * + * Im Betrieb liefert dasselbe nginx die Oberflaeche und /api aus, deshalb ist + * die Basis leer und alle Aufrufe sind gleichursprünglich (same-origin). Damit + * traegt das Sitzungscookie ohne CORS, und es gibt keine Herkunftsliste zu pflegen. + * + * Sollte die Oberflaeche einmal auf einem eigenen Host liegen, wird hier die + * absolute Backend-Adresse eingetragen; dann braucht das Backend zusaetzlich + * eine CORS-Freigabe mit Anmeldedaten und beide Hosts muessen dieselbe Site sein. + */ +type AppConfig = { + readonly api: { readonly baseUrl: string }; + readonly session: { + /** Sitzung im Hintergrund am Leben halten. */ + readonly refreshMs: number; + /** Nach dieser Zeit ohne Eingabe wird abgemeldet. */ + readonly idleMs: number; + }; + readonly polling: { + readonly ticketsMs: number; + }; +}; + +const shared = { + session: { refreshMs: 5 * 60 * 1000, idleMs: 60 * 60 * 1000 }, + polling: { ticketsMs: 20_000 }, +} as const; + +const development: AppConfig = { api: { baseUrl: '' }, ...shared }; +const production: AppConfig = { api: { baseUrl: '' }, ...shared }; + +export const config: AppConfig = import.meta.env.PROD ? production : development; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..673d55b --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,20 @@ +/** Datums- und Zeitformate, wie sie im Büro gelesen werden. */ + +export function formatDateTime(value?: string): string { + if (!value) return '–'; + + const date = new Date(value); + + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(date); +} + +export function formatDate(value?: string): string { + if (!value) return '–'; + + // Mittag statt Mitternacht: so kippt ein reines Datum nicht über die Zeitzone. + const date = new Date(`${value.slice(0, 10)}T12:00:00`); + + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date); +} diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts new file mode 100644 index 0000000..f00b0ca --- /dev/null +++ b/frontend/src/lib/version.ts @@ -0,0 +1,2 @@ +// Bei jedem EK-DOS-WEB-Update hier die sichtbare Versionsnummer erhöhen. +export const EKDOS_VERSION = "V3.0"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..8ef3c99 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import { App } from '@/App'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import '@/styles/globals.css'; +import '@/styles/dashboard-fixes.css'; +import '@/styles/users.css'; + +const container = document.getElementById('root'); + +if (container === null) throw new Error('Das Wurzelelement #root fehlt in index.html.'); + +createRoot(container).render( + + + , +); diff --git a/frontend/src/styles/dashboard-fixes.css b/frontend/src/styles/dashboard-fixes.css new file mode 100644 index 0000000..3e96399 --- /dev/null +++ b/frontend/src/styles/dashboard-fixes.css @@ -0,0 +1,21 @@ +/* Dashboard-Korrekturen nach Designabnahme */ +.app-nav button:nth-of-type(even)>span:first-child{background:#173a57;color:#62b7ff} +.helga-card{grid-column:3;grid-row:1} +.dashboard-inbox{grid-column:2} +.helga-photo{display:block;overflow:hidden;background:#102e49} +.helga-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 24%} +.helga-card{grid-template-columns:309px minmax(0,1fr);min-height:369px}.helga-photo{width:309px;height:369px} +.app-nav button .nav-chevron{margin-left:auto} +.app-nav button .nav-chevron+.nav-badge{margin-left:8px} +.dashboard-offers{display:grid;grid-column:3;grid-row:2;align-content:start}.dashboard-offers>button{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:15px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.dashboard-offers b{display:block;font-size:13px}.dashboard-offers small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.dashboard-offers em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.dashboard-offers>button:hover b{color:#65bbff} +.customer-row-actions{display:flex;justify-content:flex-end;gap:8px;white-space:nowrap} +.customers-table .customer-row-actions button{border:1px solid #4a7690;border-radius:7px;padding:7px 9px;background:#183645;color:#d8edf5;font-size:10px;font-weight:900;cursor:pointer} +.customers-table .customer-row-actions button:first-child{border-color:#79c96c;background:#19412d;color:#d9ffc7} +.customers-table .customer-row-actions button:hover{background:#245168;color:#fff}.customers-table .customer-row-actions button:first-child:hover{background:#256b36} +.invoice-modal{width:min(760px,100%)}.invoice-modal-hint{margin:-10px 0 0;color:#a9c1ce;font-size:13px}.invoice-property-list{display:grid;gap:10px}.invoice-property-card{overflow:hidden;border:1px solid #3c6172;border-radius:10px;background:#102633}.invoice-property-card summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:15px 16px;cursor:pointer;list-style:none}.invoice-property-card summary::-webkit-details-marker{display:none}.invoice-property-card summary:hover{background:#173442}.invoice-property-card summary span:nth-child(2){display:grid;gap:3px}.invoice-property-card summary b{color:#f0f8fb;font-size:16px}.invoice-property-card summary small{color:#9be97c;font-size:11px;font-weight:800;text-transform:uppercase}.invoice-property-icon{display:grid;place-items:center;width:33px;height:33px;border:1px solid #75d862;border-radius:8px;background:#19412d;color:#a9f78a;font-size:19px}.invoice-property-card summary i{color:#9be97c;font-size:19px;font-style:normal}.invoice-property-card[open] summary{border-bottom:1px solid #3c6172}.invoice-items{display:grid;gap:8px;padding:12px;background:#0c1d27}.invoice-item{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px;border:1px solid #314e5d;border-radius:8px;background:#0d202a}.invoice-item span{display:grid;gap:3px}.invoice-item b{color:#e9f4f8;font-size:13px}.invoice-item small{color:#a9c1ce;font-size:11px}.invoice-item a,.invoice-modal-actions button{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:9px 12px;background:#184b2a;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;cursor:pointer}.invoice-item a:hover{background:#256b36;color:#fff}.invoice-modal-actions{display:flex;justify-content:flex-end;padding-top:2px}.invoice-modal-actions button{border-color:#608091;background:transparent;color:#dcecf3}@media(max-width:700px){.invoice-item{align-items:stretch;flex-direction:column}.invoice-item a{width:100%}} +@media(max-width:1270px){.helga-card{grid-column:2;grid-row:auto;grid-template-columns:180px minmax(0,1fr);min-height:216px}.helga-photo{width:180px;height:216px}.dashboard-offers{grid-column:1;grid-row:auto}} +@media(max-width:880px){.helga-card,.dashboard-inbox,.dashboard-offers{grid-column:auto;grid-row:auto}} +.activity-list .activity-status.orange{color:#ffa254} +.activity-list .activity-status.green{color:#62d589;text-shadow:0 0 8px #62d58980} +.activity-list .activity-status.blue{color:#4db5ff} +.invoices-overview-page{display:grid;gap:18px;padding:30px;min-width:0}.invoices-overview-page>.offers-heading>button{border:1px solid #4a7690;border-radius:8px;padding:10px 14px;background:#183645;color:#d8edf5;font-weight:900;cursor:pointer}.invoices-overview-page>.offers-heading>button:hover{background:#245168;color:#fff}.invoice-overview-tabs{display:flex;gap:9px;border-bottom:1px solid #294353;padding-bottom:12px}.invoice-overview-tabs button{border:1px solid #426778;border-radius:8px;padding:9px 14px;background:#102633;color:#abc2ce;font-size:12px;font-weight:900;cursor:pointer}.invoice-overview-tabs button.active,.invoice-overview-tabs button:hover{border-color:#67b9ff;background:#173a57;color:#e8f6ff}.invoice-overview-list{display:grid;gap:9px}.invoice-overview-item{display:grid;grid-template-columns:.8fr 1.4fr 1.4fr .8fr auto;align-items:center;gap:16px;padding:16px;border:1px solid #315667;border-radius:10px;background:#112b39}.invoice-overview-item>div{display:grid;gap:4px;min-width:0}.invoice-overview-item small{color:#8fb5c7;font-size:10px;font-weight:900;letter-spacing:.08em}.invoice-overview-item b{overflow:hidden;color:#edf8fb;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item span{overflow:hidden;color:#b8cbd4;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item a{display:inline-flex;align-items:center;justify-content:center;border:1px solid #8fd979;border-radius:8px;padding:9px 12px;background:#19412d;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;white-space:nowrap}.invoice-overview-item a:hover{background:#256b36;color:#fff}@media(max-width:900px){.invoices-overview-page{padding:20px}.invoice-overview-item{grid-template-columns:1fr 1fr}.invoice-overview-item a{grid-column:1/-1;width:100%}}@media(max-width:520px){.invoice-overview-item{grid-template-columns:1fr}.invoice-overview-tabs{flex-direction:column}.invoice-overview-tabs button{width:100%}} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..52d120c --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,114 @@ + +:root{--navy:#112c43;--navy2:#183e58;--line:#cbddeb;--paper:#f8fcff;--ink:#10263a;--muted:#4e6479;--lime:#98e977;--green:#45a34d;--yellow:#ffd348;--yellow2:#e5ad15}*{box-sizing:border-box}body{margin:0;background:#edf6fb;color:var(--ink);font-family:Arial,Helvetica,sans-serif}.training-shell{min-height:100vh;min-width:1060px}.app-header{height:66px;display:flex;align-items:center;background:linear-gradient(100deg,#102c43,#1e415c);color:#fff}.company{width:304px;height:100%;padding:0 25px;display:flex;align-items:center;gap:12px;border-right:1px solid #477088}.company-mark{font-size:32px;font-weight:900;font-style:italic;letter-spacing:-7px}.company-mark span{color:#a7f267}.company b{font-size:18px}.company small,.user small{display:block;color:#aee685;font-size:11px;font-weight:700;margin-top:2px}.app-title{padding-left:25px;display:grid;gap:2px}.app-title b{font-size:23px}.app-title span{font-size:16px}.user{margin-left:auto;height:100%;padding:0 24px 0 17px;display:flex;align-items:center;gap:9px;background:#17384f}.user b{font-size:15px}.user small{color:#fff;font-weight:400}.avatar{width:42px;height:42px;display:grid;place-items:center;border-radius:50%;background:linear-gradient(140deg,#f7d5bd,#8d543e);font-weight:800}.chevron{margin-left:10px}.header-icon{margin-left:10px;color:#fff;font-size:16px}.app-body{display:grid;grid-template-columns:222px 1fr;min-height:calc(100vh - 66px)}.app-nav{position:relative;display:flex;flex-direction:column;padding:22px 8px;background:linear-gradient(180deg,#112b42,#0f293e);color:#fff}.app-nav button{display:flex;align-items:center;gap:16px;border:0;border-radius:7px;padding:13px 14px;background:transparent;color:#edf6fc;text-align:left;font-size:14px;cursor:pointer}.app-nav button:hover{background:#23475e}.app-nav button span{width:21px;text-align:center;font-size:23px;font-weight:300}.app-nav .selected{background:linear-gradient(100deg,#a5ed82,#90e670);color:#153a38;font-weight:800}.system{position:absolute;bottom:0;left:18px;right:18px;padding:20px 1px;border-top:1px solid #3b596e;display:flex;gap:12px;align-items:start}.system>span{color:#76e276;font-size:18px}.system b{font-size:12px}.system small{display:block;color:#d4e1e9;margin-top:7px;font-size:11px}.lesson{padding:14px 16px 18px;background:linear-gradient(#f7fcff,#edf6fb)}.stage-bar{height:61px;display:flex;align-items:center;gap:0;padding:8px 14px;border:1px solid #c6dcf0;border-radius:8px;background:linear-gradient(90deg,#f4fcf3,#f4faff)}.stage{position:relative;flex:1;display:flex;align-items:center;gap:10px;border:0;background:transparent;text-align:left;cursor:pointer}.stage:not(:last-of-type)::after{content:"";position:absolute;right:5px;top:20px;width:74px;height:1px;background:#afcce4}.stage b{width:35px;height:35px;display:grid;place-items:center;flex:0 0 auto;border:1px solid #a9c9e3;border-radius:50%;background:#f6fbff;color:#213b53;font-size:16px}.stage span{display:grid;gap:3px}.stage strong{font-size:13px}.stage small{font-size:10px;color:#40566d}.stage.active b{border-color:#36933c;background:#45a34d;color:#fff}.stage.active strong{color:#287530}.stage.complete b{border-color:#54a759;background:#d7f7c5;color:#237127}.restart{margin-left:18px;flex:0 0 auto;border:1px solid #9fbedb;border-radius:7px;padding:10px 16px;background:#fff;color:#1b3047;font-weight:700;cursor:pointer}.map-layout{display:grid;grid-template-columns:1fr 225px;gap:10px;margin-top:14px}.map-card{min-height:616px;padding:12px;border:1px solid #c7dff0;border-radius:7px;background-color:#fafdff;background-image:radial-gradient(#cfe0e9 1px,transparent 1px);background-size:11px 11px}.map-caption{padding:0 4px 11px;color:#52687a;font-size:12px}.process-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px 24px;align-items:start;padding:8px 10px}.flow-wrap{position:relative;display:flex;flex-direction:column;align-items:center;min-height:87px}.flow-node{width:100%;min-height:45px;display:grid;place-items:center;padding:7px;border:1px solid #405052;border-radius:5px;box-shadow:0 2px 4px #334b5059;text-align:center;font-size:11px;line-height:1.15;font-weight:700;background:#fff}.flow-node.green{background:linear-gradient(#b7f19a,#a6ed82)}.flow-node.yellow{background:linear-gradient(#ffe473,#ffcf33)}.flow-node:not(.lit){filter:saturate(.65);opacity:.52}.flow-node.lit{outline:2px solid #3eaf4b;outline-offset:1px;filter:none;opacity:1}.flow-arrow{margin-top:3px;font-style:normal;font-size:20px;line-height:18px;color:#1b3047}.legend{height:max-content;padding:17px 16px;border:1px solid #c6dcf0;border-radius:8px;background:#fff;font-size:12px}.legend>div{display:flex;align-items:center;gap:11px;margin-bottom:13px}.legend-dot{width:20px;height:20px;border-radius:50%;display:inline-block}.bright{background:#99ef7f}.green-dot{background:#4ba750}.yellow-dot{background:#ffc936}.legend hr{border:0;border-top:1px solid #d5e1e9;margin:20px 0 15px}.legend b{font-size:12px}.legend p{padding-left:26px;line-height:1.6;color:#344d60;font-size:11px}.explanation{display:grid;grid-template-columns:43px 1.25fr 1fr 1fr 1.2fr 136px;gap:14px;align-items:start;margin-top:14px;padding:13px 13px;border:1px solid #c6dcf0;border-radius:8px;background:#fff}.lesson-number{width:34px;height:34px;display:grid;place-items:center;border-radius:50%;background:#44a84c;color:#fff;font-size:18px;font-weight:800}.explain-main h2{margin:0 0 8px;font-size:18px}.explain-main p,.explain-col p{margin:0;color:#40566b;font-size:11px;line-height:1.45}.explain-col{padding-left:13px;border-left:1px solid #d5e4ef;font-size:11px}.explain-col b{font-size:11px}.explain-col ul{margin:8px 0 0;padding-left:18px;color:#40566b;line-height:1.55}.helga{padding-left:13px;border-left:1px solid #d5e4ef;font-size:11px}.helga p{margin:7px 0 0;padding:9px;border-radius:5px;background:#eef5fa;color:#374e60;line-height:1.4}.next{align-self:center;border:0;border-radius:6px;padding:12px 14px;background:linear-gradient(90deg,#3d9f39,#56bb45);color:#fff;font-size:14px;font-weight:800;cursor:pointer}.next span{font-size:25px;vertical-align:-2px;margin-left:6px}@media(max-width:1200px){.training-shell{min-width:0}.company{width:240px}.process-grid{grid-template-columns:repeat(3,1fr)}.explanation{grid-template-columns:43px 1fr 1fr 1fr}.helga{grid-column:2/4}.next{grid-column:4}.map-layout{grid-template-columns:1fr 200px}}@media(max-width:800px){.app-header{height:59px}.company{width:auto;padding:0 14px;border:0}.company div,.app-title span,.user div,.header-icon{display:none}.app-title{padding-left:8px}.app-title b{font-size:18px}.app-body{grid-template-columns:1fr}.app-nav{display:flex;overflow:auto;flex-direction:row;padding:8px}.app-nav button{white-space:nowrap;padding:8px 10px}.app-nav button span{display:none}.system{display:none}.lesson{padding:10px}.stage-bar{overflow:auto;height:auto}.stage{min-width:170px}.restart{margin-left:8px}.map-layout{grid-template-columns:1fr}.legend{display:none}.process-grid{grid-template-columns:repeat(2,1fr);gap:9px}.map-card{min-height:auto}.explanation{grid-template-columns:34px 1fr}.explain-main{grid-column:2}.explain-col,.helga{grid-column:2;border-left:0;padding-left:0;border-top:1px solid #d5e4ef;padding-top:10px}.next{grid-column:2;margin-top:6px}.flow-node{font-size:10px}} + +.exercise-card{margin-top:14px;padding:20px 22px;border:1px solid #c6dcf0;border-radius:8px;background:#fff}.exercise-heading{display:flex;align-items:center;gap:10px;font-size:13px}.exercise-heading span{padding:4px 7px;border-radius:4px;background:#e5f7dc;color:#287a31;font-size:10px;font-weight:800;letter-spacing:.08em}.exercise-card h3{max-width:900px;margin:12px 0 15px;font-size:17px;line-height:1.35}.exercise-answers{display:grid;gap:8px}.exercise-answers button{display:flex;align-items:center;gap:10px;width:100%;padding:10px 12px;border:1px solid #c7dbe8;border-radius:6px;background:#fff;color:#1e3c51;text-align:left;font-size:12px;cursor:pointer}.exercise-answers button:hover:not(:disabled){background:#f1fbf3;border-color:#75ba79}.exercise-answers i{width:23px;height:23px;display:grid;place-items:center;flex:0 0 auto;border:1px solid #9eb8cb;border-radius:50%;font-style:normal;font-weight:800;font-size:11px}.exercise-answers button.correct{border-color:#45a34d;background:#effbe9}.exercise-answers button.correct i{border-color:#45a34d;background:#45a34d;color:#fff}.exercise-answers button.wrong{border-color:#d66b6b;background:#fff4f3}.exercise-answers button.wrong i{border-color:#d66b6b;background:#d66b6b;color:#fff}.exercise-note{margin-top:12px;padding:10px 12px;border-radius:6px;background:#fff4f3;color:#8a4040;font-size:12px;line-height:1.4}.exercise-note.good{background:#edf9ef;color:#216c2c}@media(max-width:800px){.exercise-card{padding:16px}.exercise-card h3{font-size:15px}} +.company{padding:0 20px}.company img{width:178px;height:auto;display:block}.home-page{width:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#f6fbff,#e9f5fa);overflow:hidden}.welcome-hero{position:relative;min-height:410px;display:grid;grid-template-columns:1.04fr .96fr;align-items:center;overflow:hidden;border-radius:19px;background:linear-gradient(116deg,#102f46 0%,#16445d 53%,#1d5d61 100%);box-shadow:0 20px 35px #234f6b26}.welcome-hero::after{content:"";position:absolute;width:520px;height:520px;right:-210px;top:-280px;border-radius:50%;background:#8fe5721f;filter:blur(3px)}.hero-copy{z-index:1;padding:clamp(34px,5vw,70px);color:#fff}.home-kicker{margin:0;color:#8de876;font-size:11px;font-weight:800;letter-spacing:.13em}.hero-copy h2{margin:11px 0 16px;font-size:clamp(38px,4.7vw,64px);line-height:.96;letter-spacing:-.055em}.hero-copy h2 em{color:#9aeb75;font-style:normal}.hero-copy>p:not(.home-kicker){max-width:480px;margin:0;color:#d4e8ef;font-size:16px;line-height:1.55}.hero-actions{display:flex;gap:11px;margin-top:28px}.primary-action,.secondary-action{border-radius:8px;padding:13px 17px;font-size:14px;font-weight:800;cursor:pointer}.primary-action{border:1px solid #91e86f;background:#91e86f;color:#113847}.primary-action span{font-size:20px;vertical-align:-2px;margin-left:6px}.secondary-action{border:1px solid #a7cbd6;background:#ffffff0b;color:#fff}.hero-visual{position:relative;z-index:1;height:100%;min-height:410px}.hero-visual img{width:100%;height:100%;object-fit:cover;mix-blend-mode:screen;opacity:.92}.hero-glass{position:absolute;right:28px;bottom:27px;display:grid;gap:4px;padding:13px 16px;border:1px solid #dffbd488;border-radius:10px;background:#0b304cb8;color:#fff;backdrop-filter:blur(9px);font-size:13px}.hero-glass span{color:#b8dfcc;font-size:11px}.home-intro{display:flex;justify-content:space-between;gap:40px;align-items:end;margin:44px 4px 20px}.home-intro h3{margin:7px 0 0;font-size:28px;letter-spacing:-.035em}.home-intro>p{max-width:460px;margin:0;color:#506a7e;font-size:14px;line-height:1.5}.home-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.home-cards button{position:relative;min-height:180px;padding:23px;border:1px solid #c7dce9;border-radius:12px;background:#fff;color:#17384e;text-align:left;cursor:pointer;box-shadow:0 7px 13px #38627c0d;transition:.18s}.home-cards button:hover{transform:translateY(-3px);border-color:#6cc55a;box-shadow:0 12px 21px #29507021}.card-number{display:block;margin-bottom:13px;color:#4aa34d;font-size:12px;font-weight:900;letter-spacing:.13em}.home-cards b{display:block;font-size:18px}.home-cards small{display:block;margin-top:4px;color:#2d8451;font-size:11px;font-weight:800}.home-cards p{margin:12px 20px 0 0;color:#5e7280;font-size:13px;line-height:1.42}.home-cards i{position:absolute;right:20px;bottom:20px;color:#46a84b;font-size:24px;font-style:normal}.home-status{display:flex;align-items:center;gap:15px;margin-top:18px;padding:18px 21px;border:1px solid #c5e4c5;border-radius:11px;background:#f3fbf0}.status-icon{width:35px;height:35px;display:grid;place-items:center;flex:0 0 auto;border-radius:50%;background:#4baa50;color:#fff;font-weight:900}.home-status b{font-size:14px}.home-status p{margin:4px 0 0;color:#557066;font-size:12px}.home-status button{margin-left:auto;border:0;border-radius:7px;padding:10px 14px;background:#1d5a6a;color:#fff;font-weight:800;cursor:pointer}@media(max-width:1000px){.welcome-hero{grid-template-columns:1fr}.hero-visual{min-height:260px}.hero-copy{padding-bottom:38px}.home-cards{grid-template-columns:1fr}.home-intro{align-items:start;flex-direction:column;gap:12px}.home-status{align-items:start;flex-wrap:wrap}.home-status button{margin-left:50px}}@media(max-width:800px){.company{padding:0 12px}.company img{width:135px}.home-page{padding:18px 12px}.welcome-hero{min-height:0}.hero-copy{padding:34px 28px}.hero-copy h2{font-size:39px}.hero-actions{flex-direction:column}.hero-visual{min-height:220px}.home-intro{margin-top:30px}.home-intro h3{font-size:24px}} +.settings-wrap{position:relative;margin:8px 0 0;padding:0 9px}.settings-button{display:flex!important;width:100%;align-items:center;gap:16px!important;border:0;border-radius:7px;padding:11px 14px!important;background:transparent;color:#edf6fc;cursor:pointer;font-size:13px}.settings-button:hover{background:#23475e}.settings-button span{width:21px;text-align:center;font-size:17px!important}.theme-menu{position:absolute;z-index:5;top:43px;left:9px;width:194px;padding:12px;border:1px solid #91afbd;border-radius:10px;background:#fff;color:#18374d;box-shadow:0 12px 25px #071e2f66}.theme-menu>b{display:block;margin:0 5px 8px;font-size:11px;letter-spacing:.08em}.theme-choice{display:flex!important;align-items:center;gap:8px!important;width:100%;padding:8px!important;border:0;border-radius:6px;background:transparent;color:#25445a;cursor:pointer;font-size:12px;text-align:left}.theme-choice:hover,.theme-choice.active{background:#e8f6e2;color:#237238;font-weight:800}.theme-choice i{width:14px;font-style:normal}.settings-wrap~.system{bottom:0} +.welcome-hero .hero-brand{position:absolute;z-index:2;top:56%;left:clamp(825px,45vw,1000px);display:flex;align-items:center;gap:36px;width:max-content;margin:0;padding:0;border:0;border-radius:0;background:none;box-shadow:none;color:#fff;text-align:left;transform:translateY(-50%);pointer-events:none}.hero-brand>strong{font-size:190px;font-weight:950;font-style:italic;letter-spacing:-31px;line-height:.72;background:linear-gradient(110deg,#fff 0 43%,#9aeb75 44%);background-clip:text;-webkit-background-clip:text;color:transparent}.hero-brand span{display:grid;gap:16px}.hero-brand b{font-size:62px;line-height:1;letter-spacing:-.045em}.hero-brand small{color:#a9ec8b;font-size:27px;font-weight:800;letter-spacing:.01em}.hero-actions{margin-top:29px}.home-cards{margin-top:38px}@media(max-width:1100px){.welcome-hero .hero-brand{left:52%;top:57%;transform:translate(-10%,-50%)}.hero-brand>strong{font-size:125px}.hero-brand b{font-size:41px}.hero-brand small{font-size:18px}}@media(max-width:800px){.welcome-hero .hero-brand{position:static;width:100%;gap:12px;margin:26px 0 8px;transform:none;pointer-events:auto}.hero-brand>strong{font-size:55px;letter-spacing:-10px}.hero-brand span{gap:4px}.hero-brand b{font-size:21px}.hero-brand small{font-size:11px}} +:root[data-theme="dark"] body{background:#0d1821;color:#e4eef5}:root[data-theme="dark"] .lesson,:root[data-theme="dark"] .home-page{background:linear-gradient(135deg,#101e29,#162a36)}:root[data-theme="dark"] .stage-bar,:root[data-theme="dark"] .map-card,:root[data-theme="dark"] .legend,:root[data-theme="dark"] .explanation,:root[data-theme="dark"] .exercise-card,:root[data-theme="dark"] .home-cards button{background-color:#172a36;color:#e5eff5;border-color:#34505f}:root[data-theme="dark"] .map-card{background-image:radial-gradient(#3a5664 1px,transparent 1px)}:root[data-theme="dark"] .stage-bar{background:linear-gradient(90deg,#172e31,#172a37)}:root[data-theme="dark"] .stage small,:root[data-theme="dark"] .map-caption,:root[data-theme="dark"] .legend p,:root[data-theme="dark"] .explain-main p,:root[data-theme="dark"] .explain-col p,:root[data-theme="dark"] .home-intro>p,:root[data-theme="dark"] .home-cards p,:root[data-theme="dark"] .home-status p{color:#b4c5cf}:root[data-theme="dark"] .flow-node:not(.lit){opacity:.35}:root[data-theme="dark"] .answer,:root[data-theme="dark"] .exercise-answers button{background:#132630;color:#e3eff5;border-color:#3a5868}:root[data-theme="dark"] .helga p{background:#102633;color:#d6e4ed}:root[data-theme="dark"] .home-status{background:#163023;border-color:#39704a;color:#e4f3e5}:root[data-theme="dark"] .theme-menu{background:#1b303d;color:#e6eff3;border-color:#476473}:root[data-theme="dark"] .theme-choice{color:#e1eef5}:root[data-theme="dark"] .theme-choice:hover,:root[data-theme="dark"] .theme-choice.active{background:#2c563b;color:#c5f4b7} +@media(prefers-color-scheme:dark){:root[data-theme="system"] body{background:#0d1821;color:#e4eef5}:root[data-theme="system"] .lesson,:root[data-theme="system"] .home-page{background:linear-gradient(135deg,#101e29,#162a36)}:root[data-theme="system"] .home-cards button,:root[data-theme="system"] .home-status{background:#172a36;color:#e5eff5;border-color:#34505f}} + +/* Hero-Wortmarke muss über dem Bild liegen und vollständig sichtbar bleiben. */ +.hero-copy{position:relative;z-index:3}.hero-visual{z-index:1}.welcome-hero .hero-brand{z-index:4}.hero-brand>strong{letter-spacing:-5px} + +/* Einstellungsmenü: grün, kontrastreich und unabhängig von der Navigationsfarbe. */ +.theme-menu{background:linear-gradient(145deg,#f3ffef,#d9f4d1);border-color:#70ba69;box-shadow:0 14px 30px #133a2c73}.theme-menu>b{color:#17603a}.theme-menu .theme-choice{margin-top:5px;border:1px solid #b9e0b2;background:#effbe9;color:#195237}.theme-menu .theme-choice:hover,.theme-menu .theme-choice.active{border-color:#4ba84f;background:#c9f1bd;color:#145f30}.theme-menu .theme-choice i{color:#16823d;font-weight:900}:root[data-theme="dark"] .theme-menu{background:linear-gradient(145deg,#214638,#15342c);border-color:#75c46e}:root[data-theme="dark"] .theme-menu>b,:root[data-theme="dark"] .theme-menu .theme-choice{color:#e3fbd9}:root[data-theme="dark"] .theme-menu .theme-choice{border-color:#4f8760;background:#1f4d39}:root[data-theme="dark"] .theme-menu .theme-choice:hover,:root[data-theme="dark"] .theme-menu .theme-choice.active{border-color:#87dc78;background:#356d47;color:#efffea} + +/* Die Startseite nutzt die frei gewordene Fläche für die Markeninszenierung. */ +.home-page{display:flex;flex-direction:column;min-height:calc(100vh - 66px)}.welcome-hero{flex:1;min-height:540px}.hero-visual{min-height:540px}.home-status{flex:0 0 auto}@media(max-width:800px){.home-page{display:block;min-height:auto}.welcome-hero,.hero-visual{min-height:0}} + +/* Wortmarke exakt auf der Trennkante zwischen Farbfläche und Bild zentrieren. */ +.welcome-hero .hero-brand{left:100%;transform:translate(-50%,-50%)}.hero-brand>strong{display:inline-block;padding-right:24px;letter-spacing:10px}@media(max-width:1100px){.welcome-hero .hero-brand{left:100%;transform:translate(-50%,-50%)}}@media(max-width:800px){.welcome-hero .hero-brand{position:static;transform:none}.hero-brand>strong{padding-right:0;letter-spacing:-2px}} + +/* Anmeldung */ +.login-page{display:grid;grid-template-columns:minmax(430px,.92fr) 1.08fr;min-height:100vh;background:#102d44;color:#fff}.login-panel{z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;padding:clamp(42px,8vw,150px);background:linear-gradient(145deg,#102c43,#17475a)}.login-panel>img{width:210px;height:auto;margin-bottom:58px}.login-kicker{margin:0;color:#9bed7a;font-size:12px;font-weight:900;letter-spacing:.15em}.login-panel h1{margin:12px 0 10px;font-size:48px;letter-spacing:-.05em}.login-copy{max-width:390px;margin:0;color:#d5e6ef;font-size:16px;line-height:1.5}.login-accounts{display:flex;gap:10px;width:100%;margin:31px 0 18px}.login-accounts button{display:flex;align-items:center;gap:11px;flex:1;border:1px solid #5e8794;border-radius:10px;padding:12px;background:#ffffff0b;color:#e8f6fb;text-align:left;cursor:pointer}.login-accounts button.active{border-color:#9be97c;background:#9be97c1f;box-shadow:inset 0 0 0 1px #9be97c55}.login-accounts b{width:32px;height:32px;display:grid;place-items:center;border-radius:50%;background:#f1c9ae;color:#17384e;font-size:11px}.login-accounts button:nth-child(2) b{background:#a9ed82}.login-accounts span{display:grid;gap:2px;font-size:14px;font-weight:800}.login-accounts small{color:#b7d1db;font-size:10px;font-weight:400}.login-panel form{width:min(420px,100%);display:grid;gap:9px}.login-panel label{display:grid;gap:7px;color:#d6e8ef;font-size:12px;font-weight:800}.login-panel input{width:100%;border:1px solid #6f98a6;border-radius:8px;padding:13px 14px;background:#0c2639;color:#fff;font:inherit;outline:none}.login-panel input:focus{border-color:#9be97c;box-shadow:0 0 0 3px #9be97c2e}.login-panel form>button{margin-top:10px;border:0;border-radius:8px;padding:14px;background:#9be97c;color:#113a47;font-size:14px;font-weight:900;cursor:pointer}.login-panel form>button:disabled{opacity:.65;cursor:wait}.login-error{margin:3px 0 0;color:#ffd0cc;font-size:12px}.login-visual{position:relative;overflow:hidden}.login-visual::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,#102d44 0%,transparent 26%)}.login-visual img{width:100%;height:100%;object-fit:cover}.login-loading{margin:auto;color:#cfe4ec;font-size:16px}.session-user{display:flex;align-items:center;gap:9px;margin-left:auto;height:100%;padding:0 22px;background:#17384f}.session-user>b{width:33px;height:33px;display:grid;place-items:center;border-radius:50%;background:#a9ed82;color:#164053;font-size:13px}.session-user span{display:grid;gap:2px;font-size:13px;font-weight:800}.session-user small{color:#c6dbe3;font-size:10px;font-weight:400}.session-user button{border:1px solid #7094a7;border-radius:6px;padding:7px 9px;background:transparent;color:#e7f3f7;font-size:11px;cursor:pointer}@media(max-width:800px){.login-page{grid-template-columns:1fr}.login-visual{display:none}.login-panel{min-height:100vh;padding:38px 28px}.login-panel>img{width:170px;margin-bottom:42px}.login-panel h1{font-size:40px}.session-user{padding:0 12px}.session-user span{display:none}.session-user button{font-size:10px}} +.login-page{display:grid;grid-template-columns:minmax(430px,.92fr) 1.08fr;min-height:100vh;background:#102d44;color:#fff}.login-panel{z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;padding:clamp(42px,8vw,150px);background:linear-gradient(145deg,#102c43,#17475a)}.login-brand{display:flex;align-items:center;gap:36px;margin-bottom:62px;color:#fff}.login-brand>strong{display:inline-block;padding-right:24px;font-size:190px;font-weight:950;font-style:italic;letter-spacing:10px;line-height:.72;background:linear-gradient(110deg,#fff 0 43%,#9aeb75 44%);background-clip:text;-webkit-background-clip:text;color:transparent}.login-brand span{display:grid;gap:16px}.login-brand b{font-size:62px;line-height:1;letter-spacing:-.045em}.login-brand small{color:#a9ec8b;font-size:27px;font-weight:800;letter-spacing:.01em}.login-kicker{margin:0;color:#9bed7a;font-size:23px;font-weight:900;letter-spacing:.15em}.login-panel h1{margin:12px 0 10px;font-size:64px;letter-spacing:-.05em}.login-copy{max-width:480px;margin:0;color:#d5e6ef;font-size:17px;line-height:1.5}.login-accounts{display:flex;gap:10px;width:100%;margin:31px 0 18px}.login-accounts button{display:flex;align-items:center;gap:11px;flex:1;border:1px solid #5e8794;border-radius:10px;padding:12px;background:#ffffff0b;color:#e8f6fb;text-align:left;cursor:pointer}.login-accounts button.active{border-color:#9be97c;background:#9be97c1f;box-shadow:inset 0 0 0 1px #9be97c55}.login-accounts b{width:32px;height:32px;display:grid;place-items:center;border-radius:50%;background:#f1c9ae;color:#17384e;font-size:11px}.login-accounts button:nth-child(2) b{background:#a9ed82}.login-accounts span{display:grid;gap:2px;font-size:14px;font-weight:800}.login-accounts small{color:#b7d1db;font-size:10px;font-weight:400}.login-panel form{width:min(520px,100%);display:grid;gap:9px}.login-panel label{display:grid;gap:7px;color:#d6e8ef;font-size:12px;font-weight:800}.login-panel input{width:100%;border:1px solid #6f98a6;border-radius:8px;padding:13px 14px;background:#0c2639;color:#fff;font:inherit;outline:none}.login-panel input:focus{border-color:#9be97c;box-shadow:0 0 0 3px #9be97c2e}.login-panel form>button{margin-top:10px;border:0;border-radius:8px;padding:14px;background:#9be97c;color:#113a47;font-size:14px;font-weight:900;cursor:pointer}.login-panel form>button:disabled{opacity:.65;cursor:wait}.login-error{margin:3px 0 0;color:#ffd0cc;font-size:12px}.login-visual{position:relative;overflow:hidden}.login-visual::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,#102d44 0%,transparent 26%)}.login-visual img{width:100%;height:100%;object-fit:cover}.login-loading{margin:auto;color:#cfe4ec;font-size:16px}.session-user{display:flex;align-items:center;gap:9px;margin-left:auto;height:100%;padding:0 22px;background:#17384f}.session-user>b{width:33px;height:33px;display:grid;place-items:center;border-radius:50%;background:#a9ed82;color:#164053;font-size:13px}.session-user span{display:grid;gap:2px;font-size:13px;font-weight:800}.session-user small{color:#c6dbe3;font-size:10px;font-weight:400}.session-user button{border:1px solid #7094a7;border-radius:6px;padding:7px 9px;background:transparent;color:#e7f3f7;font-size:11px;cursor:pointer}@media(max-width:1000px){.login-brand>strong{font-size:125px}.login-brand b{font-size:41px}.login-brand small{font-size:18px}}@media(max-width:800px){.login-page{grid-template-columns:1fr}.login-visual{display:none}.login-panel{min-height:100vh;padding:38px 28px}.login-brand{gap:12px;margin-bottom:43px}.login-brand>strong{padding-right:0;font-size:75px;letter-spacing:-2px}.login-brand b{font-size:25px}.login-brand small{font-size:11px}.login-kicker{font-size:17px}.login-panel h1{font-size:44px}.session-user{padding:0 12px}.session-user span{display:none}.session-user button{font-size:10px}} + +/* Offene Tickets: Daten kommen aus dem geschützten n8n-Übergabe-Workflow. */ +.nav-count{display:grid;place-items:center;min-width:22px;height:22px;margin-left:auto;border-radius:50%;background:#f8fbff;color:#1f7040;font-size:11px;font-style:normal;font-weight:900}.tickets-page{width:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#f6fbff,#e9f5fa)}.tickets-heading{display:flex;justify-content:space-between;gap:28px;align-items:end;margin-bottom:24px}.tickets-heading h2{margin:7px 0 7px;font-size:38px;letter-spacing:-.045em}.tickets-heading p:not(.home-kicker){max-width:660px;margin:0;color:#536c7d;font-size:14px;line-height:1.5}.tickets-actions{display:grid;justify-items:end;gap:9px;flex:0 0 auto}.tickets-actions span{color:#607989;font-size:11px}.tickets-actions button{border:0;border-radius:8px;padding:12px 15px;background:#2f8744;color:#fff;font-size:13px;font-weight:800;cursor:pointer}.tickets-actions button:disabled{opacity:.62;cursor:wait}.tickets-table-wrap{overflow:auto;border:1px solid #c7dce8;border-radius:12px;background:#fff;box-shadow:0 10px 24px #294f670e}.tickets-table{width:100%;min-width:950px;border-collapse:collapse}.tickets-table th{padding:13px 16px;border-bottom:1px solid #c8dce8;background:#f2f9fd;color:#3d596d;text-align:left;font-size:10px;letter-spacing:.08em;text-transform:uppercase}.tickets-table td{padding:16px;border-bottom:1px solid #e0ebf1;color:#244258;font-size:13px;vertical-align:middle}.tickets-table tbody tr:last-child td{border-bottom:0}.tickets-table tbody tr:hover{background:#f7fcf4}.tickets-table td>b{color:#173f5b;font-size:14px}.tickets-table td strong,.tickets-table td small{display:block}.tickets-table td small{margin-top:4px;color:#6a8290;font-size:11px}.ticket-status{display:inline-block;border-radius:999px;padding:6px 9px;background:#e6f7dd;color:#25753a;font-size:11px;font-weight:800}.ticket-report-status-partial{background:#ffe0df;color:#a82028;box-shadow:0 0 0 1px #ef7d7d80}.ticket-report-status-completed{background:#b9ff8f;color:#0b5c25;box-shadow:0 0 12px #8dff559c,0 0 0 1px #d9ffc4;font-weight:950}.tickets-empty,.tickets-error{max-width:720px;border-radius:12px;padding:25px}.tickets-empty{border:1px solid #c7dce8;background:#fff;color:#2d4d62}.tickets-empty b{font-size:16px}.tickets-empty p{margin:8px 0 0;color:#647987;font-size:13px;line-height:1.55}.tickets-error{border:1px solid #efb8b2;background:#fff5f3;color:#8a3430;font-size:13px}@media(max-width:800px){.tickets-page{padding:22px 14px}.tickets-heading{align-items:start;flex-direction:column}.tickets-heading h2{font-size:31px}.tickets-actions{justify-items:start}.nav-count{display:none}} +:root[data-theme="dark"] .tickets-page{background:linear-gradient(135deg,#101e29,#162a36)}:root[data-theme="dark"] .tickets-table-wrap,:root[data-theme="dark"] .tickets-empty{border-color:#34505f;background:#172a36;color:#e5eff5}:root[data-theme="dark"] .tickets-table th{border-color:#34505f;background:#1d3544;color:#c2d5df}:root[data-theme="dark"] .tickets-table td{border-color:#294553;color:#d8e7ef}:root[data-theme="dark"] .tickets-table td>b{color:#dff3ff}:root[data-theme="dark"] .tickets-table td small,:root[data-theme="dark"] .tickets-heading p:not(.home-kicker),:root[data-theme="dark"] .tickets-actions span,:root[data-theme="dark"] .tickets-empty p{color:#b4c5cf}:root[data-theme="dark"] .tickets-table tbody tr:hover{background:#1c3640} +.ticket-consultation-row:hover{background:transparent!important}.ticket-consultation-row td{padding:0!important}.ticket-consultation{display:grid;grid-template-columns:minmax(175px,.7fr) minmax(230px,1fr) minmax(290px,1.2fr);gap:18px;align-items:start;margin:0;padding:16px 36px;border-left:4px solid #9be97c;border-radius:0;background:#f4fbf1}.ticket-consultation p{margin:0 0 5px;color:#367043;font-size:10px;font-weight:900;letter-spacing:.1em}.ticket-consultation>b{color:#173f2e;font-size:14px}.ticket-consultation-result{display:grid;gap:5px}.ticket-consultation-result>span,.ticket-consultation-edit label{color:#436478;font-size:11px;font-weight:900}.ticket-consultation-result strong{color:#213f50;font-size:13px;line-height:1.45;white-space:pre-wrap}.ticket-consultation-result small{color:#66808f;font-size:10px}.ticket-consultation-result em{color:#677f8c;font-size:12px;font-style:italic}.ticket-consultation-edit{display:grid;grid-template-columns:1fr auto;gap:7px}.ticket-consultation-edit label{grid-column:1/-1}.ticket-consultation-edit textarea{min-height:72px;border:1px solid #a4cbb2;border-radius:7px;padding:9px;background:#fff;color:#1e3c4a;font:inherit;font-size:12px;line-height:1.4;resize:vertical}.ticket-consultation-edit button{align-self:end;border:0;border-radius:7px;padding:10px 12px;background:#2f8744;color:#fff;font-size:11px;font-weight:900;cursor:pointer}.ticket-consultation-edit button:disabled{opacity:.6;cursor:wait}.ticket-consultation-error{grid-column:1/-1;color:#a63731!important;font-size:11px!important}@media(max-width:1050px){.ticket-consultation{grid-template-columns:1fr}.ticket-consultation-edit{max-width:700px}}:root[data-theme="dark"] .ticket-consultation{border-left-color:#74ca67;background:#132b35}:root[data-theme="dark"] .ticket-consultation p,:root[data-theme="dark"] .ticket-consultation-edit label{color:#9be97c}:root[data-theme="dark"] .ticket-consultation>b,:root[data-theme="dark"] .ticket-consultation-result strong{color:#e7f4ed}:root[data-theme="dark"] .ticket-consultation-result>span,:root[data-theme="dark"] .ticket-consultation-result small,:root[data-theme="dark"] .ticket-consultation-result em{color:#b4c5cf}:root[data-theme="dark"] .ticket-consultation-edit textarea{border-color:#496d63;background:#0e2029;color:#e6f3f6} +.tickets-table tbody .ticket-row.ticket-tone-blue td{background:#193240}.tickets-table tbody .ticket-row.ticket-tone-green td{background:#18382f}.ticket-consultation.ticket-tone-blue{border-left-color:#67bfe7;background:#122d3c}.ticket-consultation.ticket-tone-green{border-left-color:#82db73;background:#16352d}:root[data-theme="dark"] .tickets-table tbody .ticket-row.ticket-tone-blue td{background:#193240}:root[data-theme="dark"] .tickets-table tbody .ticket-row.ticket-tone-green td{background:#18382f}:root[data-theme="dark"] .ticket-consultation.ticket-tone-blue{border-left-color:#67bfe7;background:#122d3c}:root[data-theme="dark"] .ticket-consultation.ticket-tone-green{border-left-color:#82db73;background:#16352d} + +/* Rechnungen in Prüfung: erledigte To-Do-Aufgaben bleiben sichtbar, aber eindeutig grau. */ +.tickets-table tbody tr.invoice-review-row-completed td{background:#dce2e5;color:#69757d}.tickets-table tbody tr.invoice-review-row-completed td>b{color:#59666e}.tickets-table tbody tr.invoice-review-row-completed .ticket-continuation,.tickets-table tbody tr.invoice-review-row-completed td small{color:#738088}.invoice-review-status-completed{background:#aeb8bd;color:#26363d}.invoice-review-completed-at{margin-top:5px!important;font-size:10px!important}:root[data-theme="dark"] .tickets-table tbody tr.invoice-review-row-completed td{background:#38454d;color:#aeb8bd}:root[data-theme="dark"] .tickets-table tbody tr.invoice-review-row-completed td>b{color:#c1c9cd}:root[data-theme="dark"] .tickets-table tbody tr.invoice-review-row-completed .ticket-continuation,:root[data-theme="dark"] .tickets-table tbody tr.invoice-review-row-completed td small{color:#aeb8bd}:root[data-theme="dark"] .tickets-table tbody tr.invoice-review-row-completed:hover td{background:#414f57} +.invoice-create-actions{display:flex;align-items:center;gap:8px}.invoice-create-file,.invoice-create-complete{display:inline-flex;align-items:center;justify-content:center;min-height:34px;border-radius:8px;padding:9px 12px;font-size:11px;font-weight:900}.invoice-create-file{border:1px solid #78b7cc;background:#eef8fc;color:#174c64;text-decoration:none}.invoice-create-file:hover{border-color:#2f8744;background:#e2f6dc;color:#1d7032}.invoice-create-complete{border:0;background:#9be97c;color:#113a47;cursor:pointer}.invoice-create-complete:disabled{opacity:.6;cursor:wait}:root[data-theme="dark"] .invoice-create-file{border-color:#4b7282;background:#183645;color:#d8edf5}:root[data-theme="dark"] .invoice-create-file:hover{border-color:#76c870;background:#244a38;color:#e4f8df} + +/* Schulungsübersicht: Workflow-Beschriftungen bleiben im Dunkelmodus kontrastreich schwarz. */ +:root[data-theme="dark"] .map-caption,:root[data-theme="dark"] .flow-node,:root[data-theme="dark"] .flow-node span{color:#101820} + +/* Schulungsprozess: echte Flussrichtungen und sichtbare Verzweigungen. */ +.process-map{min-height:0;padding:16px;background-image:none}.process-map .map-caption{padding:0 0 12px}.flow-lane{padding:12px 10px 14px;border-top:1px solid #c9dce8}.flow-lane:first-of-type{border-top:0}.flow-lane-title{display:block;margin:0 0 9px;color:#1c5365;font-size:11px;letter-spacing:.07em;text-transform:uppercase}.flow-row{display:flex;align-items:center;gap:8px}.flow-row>.flow-node{flex:1;min-width:120px;min-height:48px}.flow-row>i{flex:0 0 auto;color:#163d51;font-size:22px;font-style:normal;font-weight:800}.branch-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.branch-grid>div{padding:10px;border:1px solid #c9dce8;border-radius:7px;background:#f9fdff}.branch-grid small{display:block;margin:0 0 8px;color:#365c70;font-size:10px;font-weight:800;letter-spacing:.04em;text-transform:uppercase}.flow-merge{display:flex;justify-content:center;align-items:center;gap:10px;margin-top:10px;color:#163d51;font-size:20px;font-weight:800}.flow-merge span{font-size:10px;letter-spacing:.06em;text-transform:uppercase}.result-branch .branch-grid{grid-template-columns:1fr}.result-branch .flow-row>.flow-node{min-width:105px}@media(max-width:1200px){.flow-row{overflow-x:auto;padding-bottom:3px}.flow-row>.flow-node{flex:0 0 170px}.branch-grid{grid-template-columns:1fr}.result-branch .flow-row>.flow-node{flex-basis:155px}}@media(max-width:800px){.process-map{padding:10px}.flow-lane{padding:10px 2px}.branch-grid{gap:9px}.branch-grid>div{padding:8px}.flow-row>.flow-node,.result-branch .flow-row>.flow-node{flex-basis:150px}} + +/* Im Dunkelmodus gelten dieselben ruhigen Flächen – ohne Raster oder helle Karten. */ +:root[data-theme="dark"] .process-map{background-color:#172a36;background-image:none}:root[data-theme="dark"] .process-map .flow-lane{border-color:#34505f}:root[data-theme="dark"] .process-map .branch-grid>div{border-color:#34505f;background:#172a36}:root[data-theme="dark"] .process-map .flow-lane-title,:root[data-theme="dark"] .process-map .branch-grid small,:root[data-theme="dark"] .process-map .flow-row>i,:root[data-theme="dark"] .process-map .flow-merge{color:#b9d4df} + +/* Rollenbasierte Arbeitsbereiche in der Navigation. */ +.app-nav{overflow-y:auto;padding-bottom:96px}.nav-chevron{margin-left:auto;font-size:16px!important;font-style:normal;transition:transform .16s}.nav-badge+.nav-chevron{margin-left:8px}.nav-chevron.open{transform:rotate(180deg)}.nav-submenu{display:grid;gap:2px;margin:-3px 0 6px;padding:4px 0 4px 38px;border-left:1px solid #426176}.app-nav .nav-submenu button{padding:8px 10px;border-radius:5px;color:#c9dbe6;font-size:12px}.app-nav .nav-submenu button:hover,.app-nav .nav-submenu button.active{background:#244c61;color:#a4ed83;font-weight:800}.nav-role{display:grid;gap:2px;margin:8px 8px 2px;padding-top:8px;border-top:1px solid #345268}.nav-role>b{padding:0 12px 3px;color:#8fe572;font-size:10px;letter-spacing:.12em;text-transform:uppercase}.nav-role button{padding:9px 14px!important;font-size:13px!important}.workspace-page{width:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#f6fbff,#e9f5fa)}.workspace-page h2{margin:7px 0 25px;font-size:38px;letter-spacing:-.045em}.workspace-empty{max-width:730px;padding:28px;border:1px solid #c7dce8;border-radius:12px;background:#fff;color:#29495e}.workspace-empty b{font-size:17px}.workspace-empty p{margin:9px 0;color:#506c7e;font-size:14px;line-height:1.5}.workspace-empty small{display:block;margin-top:17px;color:#6a8290;font-size:12px;line-height:1.45}@media(max-width:800px){.app-nav{padding-bottom:8px;overflow-x:auto;overflow-y:visible}.nav-submenu,.nav-role{display:flex;margin:0;padding:0;border:0}.nav-role>b{display:none}.app-nav .nav-submenu button{padding:8px 10px}.workspace-page{padding:22px 14px}.workspace-page h2{font-size:31px}}:root[data-theme="dark"] .workspace-page{background:linear-gradient(135deg,#101e29,#162a36)}:root[data-theme="dark"] .workspace-empty{border-color:#34505f;background:#172a36;color:#e5eff5}:root[data-theme="dark"] .workspace-empty p,:root[data-theme="dark"] .workspace-empty small{color:#b4c5cf} + +/* Kundenstamm: vorhandene Kunden bearbeiten, keine manuelle Neuanlage. */ +.customers-page{width:100%;min-height:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#101e29,#162a36);color:#e5eff5}.customers-heading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:22px}.customers-heading h2{margin:7px 0;font-size:38px;letter-spacing:-.045em}.customers-heading p:not(.home-kicker){margin:0;color:#b4c5cf;font-size:14px}.customers-heading>button,.customer-card>button,.customer-modal-actions button:last-child{border:0;border-radius:8px;padding:12px 15px;background:#9be97c;color:#113a47;font-size:13px;font-weight:900;cursor:pointer}.customers-toolbar{display:flex;align-items:center;gap:14px;margin-bottom:16px}.customers-toolbar input{width:min(620px,100%);border:1px solid #466776;border-radius:8px;padding:12px 14px;background:#172a36;color:#e5eff5;font:inherit;outline:none}.customers-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.customer-card{display:flex;justify-content:space-between;gap:20px;min-height:150px;padding:20px;border:1px solid #34505f;border-radius:12px;background:#172a36}.customer-card h3{margin:5px 0;color:#f0f8fb;font-size:18px}.customer-card p{margin:8px 0;color:#c4d7e1;font-size:13px}.customer-card small{color:#a9c1ce;font-size:11px}.customer-number{margin:0!important;color:#8fe572!important;font-size:10px!important;font-weight:900;letter-spacing:.1em;text-transform:uppercase}.customer-person{margin:0!important;color:#9be97c!important}.customer-card>button{align-self:flex-start;background:transparent;border:1px solid #608091;color:#dcecf3;padding:8px 10px;font-size:11px}.customer-note{display:grid;gap:4px;margin-top:13px;padding:10px 11px;border-left:3px solid #9be97c;background:#0f202b;color:#cddde5;font-size:12px;line-height:1.45}.customer-note b{color:#9be97c;font-size:10px;letter-spacing:.08em;text-transform:uppercase}.customer-modal-backdrop{position:fixed;z-index:20;inset:0;display:grid;place-items:center;padding:20px;background:#061119bd}.customer-modal{display:grid;gap:18px;width:min(800px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:28px;border:1px solid #466776;border-radius:14px;background:#172a36}.customer-modal h3{margin:6px 0 0;font-size:24px}.customer-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.customer-form-grid label{display:grid;gap:6px;color:#c6d8e1;font-size:11px;font-weight:800}.customer-form-grid input,.customer-form-grid textarea{width:100%;border:1px solid #466776;border-radius:7px;padding:10px;background:#0f202b;color:#f0f8fb;font:inherit;font-size:13px}.customer-form-grid textarea{min-height:100px;resize:vertical}.customer-note-field{grid-column:1/-1}.customer-modal-actions{display:flex;justify-content:flex-end;gap:10px}.customer-modal-actions button:first-child{border:1px solid #608091;border-radius:8px;padding:12px 15px;background:transparent;color:#dcecf3;font-weight:800;cursor:pointer}@media(max-width:900px){.customers-list{grid-template-columns:1fr}}@media(max-width:700px){.customers-page{padding:22px 14px}.customers-heading{align-items:start;flex-direction:column}.customers-heading h2{font-size:31px}.customers-toolbar{align-items:stretch;flex-direction:column}.customer-form-grid{grid-template-columns:1fr}.customer-card{flex-direction:column}.customer-card>button{align-self:stretch}.customer-modal{padding:20px}} +.customers-toolbar label{display:flex;align-items:center;gap:8px;color:#b4c5cf;font-size:11px;font-weight:800;white-space:nowrap}.customers-toolbar select{border:1px solid #466776;border-radius:7px;padding:10px;background:#172a36;color:#e5eff5;font:inherit;font-size:12px}.customers-table-wrap{overflow:auto;border:1px solid #34505f;border-radius:12px;background:#172a36}.customers-table{width:100%;min-width:1080px;border-collapse:collapse}.customers-table th{padding:13px 15px;border-bottom:1px solid #34505f;background:#1d3544;color:#b9d4df;text-align:left;font-size:10px;letter-spacing:.08em;text-transform:uppercase}.customers-table td{padding:14px 15px;border-bottom:1px solid #294553;color:#d8e7ef;font-size:12px;line-height:1.45;vertical-align:middle}.customers-table tbody tr:last-child td{border-bottom:0}.customers-table tbody tr:hover{background:#1c3640}.customers-table td strong,.customers-table td small{display:block}.customers-table td strong{color:#f0f8fb;font-size:13px}.customers-table td small{margin-top:2px;color:#9be97c;font-size:11px}.customers-table td>b{color:#9be97c}.customers-table td:last-child{width:1%;white-space:nowrap}.customers-table td button{border:1px solid #608091;border-radius:6px;padding:7px 9px;background:transparent;color:#dcecf3;font-size:11px;font-weight:800;cursor:pointer}.customers-table td button:hover{border-color:#9be97c;color:#9be97c}.customer-note-cell{max-width:280px;color:#c6d8e1!important}@media(max-width:700px){.customers-toolbar label{justify-content:space-between}.customers-toolbar select{flex:1}} +.customer-modal-actions .customer-delete-button{margin-right:auto;border:1px solid #ff5b67;background:#8d1220;color:#fff;font-weight:900;box-shadow:0 0 8px #ff3b4b,0 0 18px #9d1220}.customer-modal-actions .customer-delete-button:hover{background:#bc1728;box-shadow:0 0 12px #ff5b67,0 0 26px #c91f30}.customer-modal-actions .customer-delete-button:disabled{opacity:.65;cursor:wait;box-shadow:none} +.customers-heading-actions{display:flex;align-items:center;gap:10px}.customers-heading-actions button{display:inline-flex;align-items:center;justify-content:center;min-height:42px;border:1px solid #517889;border-radius:9px;padding:10px 13px;background:#102735;color:#d7e9f2;font-size:12px;font-weight:900;line-height:1;cursor:pointer;transition:.16s}.customers-heading-actions button:hover{border-color:#76b8d2;background:#193a4a;color:#fff}.customers-heading-actions .customer-create-button{gap:8px;border-color:#9be97c;background:linear-gradient(135deg,#a9ef80,#87d961);color:#103529;box-shadow:0 5px 14px #80dd652b}.customers-heading-actions .customer-create-button::before{content:"+";display:grid;place-items:center;width:18px;height:18px;border-radius:50%;background:#1c653c;color:#eaffdf;font-size:17px;font-weight:500;line-height:1}.customers-heading-actions .customer-create-button:hover{border-color:#c5ffae;background:linear-gradient(135deg,#baf998,#94e66d);box-shadow:0 7px 18px #80dd6552}@media(max-width:700px){.customers-heading-actions{width:100%;justify-content:space-between}} +.dashboard-welcome{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:end;gap:26px}.dashboard-datetime{display:grid;justify-items:center;gap:7px;min-width:280px;margin-bottom:1px;padding:0;color:#b7cad5;text-align:center}.dashboard-datetime span{font-size:16px;font-weight:800;letter-spacing:.035em;text-transform:capitalize}.dashboard-datetime b{color:#f3f9fd;font-size:35px;letter-spacing:-.045em;line-height:1;text-shadow:0 0 18px #4aaeff66}@media(max-width:880px){.dashboard-welcome{grid-template-columns:1fr;gap:12px}.dashboard-datetime{justify-items:start;min-width:0;margin:0;padding:0;text-align:left}.dashboard-v2{justify-self:start}} +.customer-digital-file{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:12px 15px;background:#184b2a;color:#d9ffc7;font-size:13px;font-weight:900;text-decoration:none;box-shadow:0 0 8px #75e35a,0 0 18px #2f9c46}.customer-digital-file:hover{background:#256b36;color:#fff;box-shadow:0 0 12px #9be97c,0 0 26px #43bd59} + +/* Online-Käufe: Sascha erfasst, Svenja archiviert den Beleg. */ +.online-purchases-page{width:100%;min-height:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#101e29,#162a36);color:#e5eff5}.online-purchases-heading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:22px}.online-purchases-heading h2{margin:7px 0;font-size:38px;letter-spacing:-.045em}.online-purchases-heading p:not(.home-kicker){margin:0;color:#b4c5cf;font-size:14px}.online-purchases-heading>button,.online-purchase-form button,.online-purchase-status button{border:0;border-radius:8px;padding:12px 15px;background:#9be97c;color:#113a47;font-size:13px;font-weight:900;cursor:pointer}.online-purchases-heading>button:disabled,.online-purchase-form button:disabled,.online-purchase-status button:disabled{opacity:.6;cursor:wait}.online-purchase-form{display:grid;grid-template-columns:1.2fr 1.2fr .8fr auto;align-items:end;gap:12px;margin:0 0 18px;padding:18px;border:1px solid #34505f;border-radius:12px;background:#172a36}.online-purchase-form label{display:grid;gap:6px;color:#c6d8e1;font-size:11px;font-weight:800}.online-purchase-form input{width:100%;border:1px solid #466776;border-radius:7px;padding:11px;background:#0f202b;color:#f0f8fb;font:inherit;font-size:13px}.online-purchases-list{display:grid;gap:10px}.online-purchase-item{display:grid;grid-template-columns:34px 1.15fr 1.1fr .72fr minmax(135px,.8fr);align-items:center;gap:16px;padding:17px 19px;border:1px solid #34505f;border-radius:11px;background:#172a36;transition:opacity .16s,filter .16s}.online-purchase-item:hover{border-color:#547284}.online-purchase-item.is-done{opacity:.48;filter:saturate(.45)}.online-purchase-check{display:grid;place-items:center;width:27px;height:27px;border:1px solid #7091a1;border-radius:50%;color:#88a6b5;font-size:17px;font-weight:900}.online-purchase-item.is-done .online-purchase-check{border-color:#9be97c;background:#9be97c;color:#123426}.online-purchase-item small{display:block;margin-bottom:4px;color:#91adbb;font-size:9px;font-weight:900;letter-spacing:.08em}.online-purchase-item b{display:block;color:#eff8fb;font-size:14px}.online-purchase-item span:not(.online-purchase-check){color:#d2e1e8;font-size:13px}.online-purchase-status{display:grid;justify-items:end;gap:4px;text-align:right}.online-purchase-status strong{color:#9be97c;font-size:12px}.online-purchase-status small{margin:0;color:#b4c5cf;letter-spacing:0;text-transform:none}.online-purchase-status button{padding:9px 11px;font-size:11px}@media(max-width:950px){.online-purchase-form{grid-template-columns:1fr 1fr}.online-purchase-form button{grid-column:1/-1}.online-purchase-item{grid-template-columns:34px 1fr 1fr}.online-purchase-status{grid-column:2/-1;justify-items:start;text-align:left}}@media(max-width:700px){.online-purchases-page{padding:22px 14px}.online-purchases-heading{align-items:start;flex-direction:column}.online-purchases-heading h2{font-size:31px}.online-purchase-form,.online-purchase-item{grid-template-columns:1fr}.online-purchase-check{grid-row:1;justify-self:start}.online-purchase-item>div{padding-left:0}.online-purchase-status{grid-column:auto;justify-items:start;text-align:left}} + +/* Stundennachweise: beim Öffnen aus dem n8n-Workflow geladen, ohne Intervall. */ +.hours-absence-summary{display:flex;align-items:start;justify-content:space-between;gap:12px;width:min(320px,100%)}.hours-absence-summary>span{display:grid;gap:5px}.hours-absence-summary>.school{align-self:center} +.hours-summary-total{display:grid;justify-items:end;gap:4px;margin-left:auto}.hours-summary-total small{color:#a9c1ce;font-size:10px;font-weight:800}.hours-summary-total strong{color:#2c9848;font-size:22px} +.hours-absence-summary{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}.hours-absence-summary em{padding:2px 6px;border-radius:999px;font-size:10px;font-style:normal;font-weight:900}.hours-absence-summary .school{background:#54e63b;color:#102611}.hours-absence-summary .sick{background:#ff3131;color:#fff}.hours-absence-summary .vacation{background:#ffd92f;color:#18222d} +.hours-manual-warning{margin:7px 0 0;color:#ffe14f;font-size:12px;font-weight:900} +.hours-date.sick{color:#ff4b4b;font-weight:900}.hours-date.vacation{color:#ffe14f;font-weight:900}.hours-date.school{color:#73ff57;font-weight:900}.hours-status.sick{background:#ff3131;color:#fff;border:1px solid #ff7777;box-shadow:0 0 12px #ff313199}.hours-status.vacation{background:#ffd92f;color:#18222d;border:1px solid #fff08c;box-shadow:0 0 12px #ffd92f8c}.hours-status.school{background:#54e63b;color:#102611;border:1px solid #a2ff8f;box-shadow:0 0 12px #54e63ba8} +.hours-employee-list{display:grid;gap:12px}.hours-employee{border:1px solid #c7dce8;border-radius:12px;background:#fff;overflow:hidden}.hours-employee summary{display:flex;align-items:center;gap:18px;padding:19px 22px;cursor:pointer;list-style:none;color:#27485e}.hours-employee summary::-webkit-details-marker{display:none}.hours-employee summary span{display:grid;gap:4px}.hours-employee summary b{font-size:16px}.hours-employee summary small{color:#6b8290;font-size:11px}.hours-employee summary strong{margin-left:auto;color:#2c9848;font-size:22px}.hours-employee summary i{font-size:20px;font-style:normal;transition:transform .18s}.hours-employee[open] summary{border-bottom:1px solid #c7dce8;background:#f4fbf1}.hours-employee[open] summary i{transform:rotate(180deg)}.hours-employee .hours-table-wrap{border:0;border-radius:0;box-shadow:none}:root[data-theme="dark"] .hours-employee{border-color:#34505f;background:#172a36}:root[data-theme="dark"] .hours-employee summary{color:#e5eff5}:root[data-theme="dark"] .hours-employee summary small{color:#b4c5cf}:root[data-theme="dark"] .hours-employee summary strong{color:#9be97c}:root[data-theme="dark"] .hours-employee[open] summary{border-color:#34505f;background:#1d3544} +.hours-page{width:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#f6fbff,#e9f5fa)}.hours-heading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.hours-heading h2{margin:7px 0;font-size:38px;letter-spacing:-.045em}.hours-heading p{margin:0;color:#536c7d;font-size:14px}.hours-heading>span{color:#607989;font-size:11px}.hours-total-grid{display:grid;grid-template-columns:repeat(4,minmax(160px,1fr));gap:14px;margin-bottom:20px}.hours-total-grid article{display:grid;gap:6px;padding:20px;border:1px solid #c7dce8;border-radius:12px;background:#fff;color:#27485e}.hours-total-grid span{font-size:13px;font-weight:800}.hours-total-grid b{color:#2c9848;font-size:29px;letter-spacing:-.04em}.hours-total-grid small{color:#6b8290;font-size:11px}.hours-table-wrap{overflow:auto;border:1px solid #c7dce8;border-radius:12px;background:#fff;box-shadow:0 10px 24px #294f670e}.hours-table{width:100%;min-width:940px;border-collapse:collapse}.hours-table th{padding:13px 16px;border-bottom:1px solid #c8dce8;background:#f2f9fd;color:#3d596d;text-align:left;font-size:10px;letter-spacing:.08em;text-transform:uppercase}.hours-table td{padding:14px 16px;border-bottom:1px solid #e0ebf1;color:#244258;font-size:13px;vertical-align:middle}.hours-table tbody tr:last-child td{border-bottom:0}.hours-table tbody tr:hover{background:#f7fcf4}.hours-table td>b,.hours-table td small{display:block}.hours-table td small{margin-top:3px;color:#6a8290;font-size:11px}.hours-status{display:inline-block;border-radius:999px;padding:5px 8px;background:#f5e9bd;color:#765c11;font-size:11px;font-weight:800}.hours-status.work{background:#e6f7dd;color:#25753a}.hours-empty,.hours-error{max-width:720px;border-radius:12px;padding:25px}.hours-empty{border:1px solid #c7dce8;background:#fff;color:#2d4d62}.hours-error{border:1px solid #efb8b2;background:#fff5f3;color:#8a3430;font-size:13px}.hours-note{margin:15px 0 0;color:#647987;font-size:11px}@media(max-width:1000px){.hours-total-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:800px){.hours-page{padding:22px 14px}.hours-heading{align-items:start;flex-direction:column}.hours-heading h2{font-size:31px}.hours-total-grid{grid-template-columns:1fr}}:root[data-theme="dark"] .hours-page{background:linear-gradient(135deg,#101e29,#162a36)}:root[data-theme="dark"] .hours-heading p,:root[data-theme="dark"] .hours-heading>span,:root[data-theme="dark"] .hours-note{color:#b4c5cf}:root[data-theme="dark"] .hours-total-grid article,:root[data-theme="dark"] .hours-table-wrap,:root[data-theme="dark"] .hours-empty{border-color:#34505f;background:#172a36;color:#e5eff5}:root[data-theme="dark"] .hours-total-grid b{color:#9be97c}:root[data-theme="dark"] .hours-total-grid small,:root[data-theme="dark"] .hours-table td small{color:#b4c5cf}:root[data-theme="dark"] .hours-table th{border-color:#34505f;background:#1d3544;color:#c2d5df}:root[data-theme="dark"] .hours-table td{border-color:#294553;color:#d8e7ef}:root[data-theme="dark"] .hours-table tbody tr:hover{background:#1c3640} +.internal-task-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.internal-task-column{border:1px solid #35576a;border-radius:14px;padding:18px;background:#163141}.internal-task-column h3{margin:0 0 15px;color:#9aef76}.internal-task-form{display:grid;grid-template-columns:1fr 180px auto;gap:10px;align-items:end;margin-bottom:16px}.internal-task-form label,.internal-task-edit-modal label{display:grid;gap:5px;color:#b4c5cf;font-size:11px;font-weight:800}.internal-task-form input,.internal-task-form select,.internal-task-edit-modal input,.internal-task-edit-modal select{width:100%;min-height:41px;border:1px solid #466776;border-radius:8px;padding:9px 11px;background:#0f202b;color:#eef8fb;font:inherit;font-size:13px}.internal-task-form button{min-height:41px;border:0;border-radius:8px;padding:10px 13px;background:#9be97c;color:#113a47;font-size:12px;font-weight:900;cursor:pointer}.internal-task-form button:disabled{opacity:.6;cursor:wait}.internal-task-category{margin-top:12px;border-top:1px solid #35576a;padding-top:12px}.internal-task-category h4{display:flex;align-items:center;gap:8px;margin:0 0 9px;color:#e5eff5;font-size:13px}.internal-task-category h4>b{margin-left:auto;border-radius:999px;padding:2px 8px;background:#294957;color:#bdebac;font-size:11px}.internal-task-dot{width:9px;height:9px;border-radius:50%;background:#72b7ff;box-shadow:0 0 8px #72b7ff99}.internal-task-dot.steuerberater{background:#bd8cff;box-shadow:0 0 8px #bd8cff99}.internal-task-dot.kundenruecksprache{background:#ffae68;box-shadow:0 0 8px #ffae6899}.internal-task-dot.interne_bueroaufgaben{background:#9aef76;box-shadow:0 0 8px #9aef7699}.internal-task-dot.heute_erledigen{background:#ff3048;box-shadow:0 0 10px #ff3048cc}.internal-task-category>p{margin:0;padding:8px 0;color:#91adbb;font-size:12px}.internal-task-item{display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:9px;align-items:center;padding:10px 0;border-top:1px solid #294957}.internal-task-item>div:nth-child(2){display:grid;gap:3px}.internal-task-item b{color:#edf7fb;font-size:13px}.internal-task-item small{color:#91adbb;font-size:10px}.internal-task-category details{margin-top:8px}.internal-task-category summary{cursor:pointer;color:#b4c5cf;font-size:12px;font-weight:800}.internal-task-edit{border:1px solid #5d91a8!important;background:transparent!important;color:#d6e9f2!important}.internal-task-edit-backdrop{position:fixed;z-index:40;inset:0;display:grid;place-items:center;padding:20px;background:#061119cc}.internal-task-edit-modal{display:grid;gap:13px;width:min(510px,100%);padding:25px;border:1px solid #466776;border-radius:14px;background:#172a36;box-shadow:0 18px 60px #0008}.internal-task-edit-modal p{margin:0;color:#9aef76;font-size:10px;font-weight:900;letter-spacing:.12em}.internal-task-edit-modal h3{margin:0;color:#edf7fb;font-size:24px}.internal-task-edit-modal>div{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}.internal-task-edit-modal button{border:1px solid #608091;border-radius:8px;padding:10px 14px;background:transparent;color:#e5eff5;font-weight:900;cursor:pointer}.internal-task-edit-modal button:last-child{border:0;background:#9be97c;color:#113a47}.nav-badge{margin-left:auto;background:#eefbea;color:#123d24;border-radius:999px;padding:2px 7px;font-style:normal;font-size:.75rem}@media(max-width:1120px){.internal-task-columns{grid-template-columns:1fr}.internal-task-form{grid-template-columns:1fr 180px auto}}@media(max-width:640px){.internal-task-form{grid-template-columns:1fr}.internal-task-form button{width:100%}.internal-task-item{grid-template-columns:24px minmax(0,1fr)}.internal-task-item .online-purchase-status{grid-column:2;justify-content:flex-start;text-align:left}} + +/* Angebote: vorhandene n8n-Anbindung mit Versand- und Beauftragungsstatus. */ +.offers-page{width:100%;min-height:100%;padding:clamp(28px,5vw,65px);background:linear-gradient(135deg,#101e29,#162a36);color:#e5eff5}.offers-heading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:22px}.offers-heading h2{margin:7px 0;font-size:38px;letter-spacing:-.045em}.offers-heading p:not(.home-kicker){margin:0;color:#b4c5cf;font-size:14px}.offers-heading>button,.offer-actions button,.offer-commission,.offer-reset,.offer-delete{border:0;border-radius:8px;padding:10px 12px;background:#9be97c;color:#113a47;font-size:12px;font-weight:900;cursor:pointer}.offers-heading>button:disabled,.offer-actions button:disabled,.offer-commission:disabled,.offer-reset:disabled,.offer-delete:disabled{opacity:.6;cursor:wait}.offers-list{display:grid;gap:10px}.offer-item{display:grid;grid-template-columns:minmax(210px,1.2fr) minmax(170px,1fr) .7fr .9fr 1fr auto auto;align-items:center;gap:16px;padding:17px 19px;border:1px solid #34505f;border-radius:11px;background:#172a36}.offer-item.is-commissioned{border-color:#4d9d5e;background:#183a2d}.offer-item.is-expired{border-color:#ff5a5a;background:#351e27}.offer-item small{display:block;margin-bottom:4px;color:#91adbb;font-size:9px;font-weight:900;letter-spacing:.08em}.offer-item b,.offer-item strong{display:block;color:#eff8fb;font-size:14px}.offer-main span,.offer-item>div>span{display:block;color:#c5d8e2;font-size:12px;overflow-wrap:anywhere}.offer-status strong{color:#9be97c;font-size:12px}.offer-expired{color:#ff6969!important;font-weight:900}.offer-actions,.offer-open-actions{display:flex;align-items:center;gap:7px}.offer-actions select{border:1px solid #466776;border-radius:7px;padding:9px;background:#0f202b;color:#f0f8fb;font:inherit;font-size:12px}.offer-commission{background:#ffd92f;color:#18222d}.offer-reset{background:#34596b;color:#e6f4fa;border:1px solid #62889b}.offer-delete{background:transparent;border:1px solid #e17272;color:#ff9090}.offer-confirm-backdrop{position:fixed;z-index:30;inset:0;display:grid;place-items:center;padding:20px;background:#061119cc}.offer-confirm{display:grid;gap:16px;width:min(700px,100%);padding:30px;border:1px solid #ff7777;border-radius:14px;background:#172a36;box-shadow:0 18px 60px #0008}.offer-confirm>p{margin:0;color:#9be97c;font-size:11px;font-weight:900;letter-spacing:.12em}.offer-confirm>strong{color:#ff3333;font-size:clamp(25px,3vw,34px);font-weight:950;line-height:1.14}.offer-confirm>span{color:#c7d9e2;font-size:13px}.offer-confirm>div{display:flex;justify-content:flex-end;gap:10px}.offer-confirm button{border:1px solid #608091;border-radius:8px;padding:12px 20px;background:transparent;color:#e5eff5;font-weight:900;cursor:pointer}.offer-confirm button:last-child{border:0;background:#9be97c;color:#113a47}.offers-toolbar{display:flex;align-items:center;gap:10px}.offers-toolbar input{width:min(360px,42vw);border:1px solid #466776;border-radius:8px;padding:11px 13px;background:#0f202b;color:#eef8fb;font:inherit;font-size:13px}.offers-toolbar input::placeholder{color:#91adbb}@media(max-width:1100px){.offer-item{grid-template-columns:minmax(200px,1fr) 1fr 1fr;align-items:start}.offer-status{grid-column:1}.offer-actions,.offer-open-actions,.offer-commission,.offer-reset,.offer-delete{grid-row:auto}.offer-actions,.offer-open-actions{grid-column:2/-1}}@media(max-width:700px){.offers-page{padding:22px 14px}.offers-heading{align-items:start;flex-direction:column}.offers-heading h2{font-size:31px}.offer-item{grid-template-columns:1fr}.offer-status,.offer-actions,.offer-open-actions{grid-column:auto}.offer-actions,.offer-open-actions{align-items:stretch;flex-direction:column}.offer-confirm{padding:22px}.offer-confirm>strong{font-size:25px}.offers-toolbar{width:100%;align-items:stretch;flex-direction:column}.offers-toolbar input{width:100%}} + +/* iPad / Tablet quer: Die Desktop-Navigation bleibt erhalten, der Arbeitsbereich wird darunter jedoch bewusst einspaltig und touchfreundlich. */ +@media (min-width:801px) and (max-width:1280px){ + .company{width:220px;padding:0 16px}.company img{width:158px}.app-body{grid-template-columns:210px minmax(0,1fr)}.app-nav button{gap:12px;padding:12px 11px}.app-nav button span{font-size:20px}.nav-role{margin-left:4px;margin-right:4px}.nav-role button{padding:9px 11px!important} + .home-page,.customers-page,.online-purchases-page,.hours-page,.workspace-page{padding:28px 30px}.welcome-hero{grid-template-columns:1fr;min-height:0}.hero-copy{padding:38px 42px 12px}.hero-copy h2{font-size:clamp(42px,5.2vw,58px)}.welcome-hero .hero-brand{position:static;display:flex;width:100%;gap:18px;margin:24px 0 20px;transform:none}.hero-brand>strong{padding-right:0;font-size:94px;letter-spacing:-7px}.hero-brand span{gap:6px}.hero-brand b{font-size:31px}.hero-brand small{font-size:15px}.hero-visual{min-height:245px}.hero-visual img{object-position:center 55%} + .online-purchase-form{grid-template-columns:1fr 1fr}.online-purchase-form button{grid-column:1/-1}.online-purchase-item{grid-template-columns:34px minmax(0,1fr) minmax(0,1fr);align-items:start}.online-purchase-item>div:nth-of-type(3){grid-column:2;grid-row:2}.online-purchase-status{grid-column:3;grid-row:2;display:flex;align-items:center;justify-content:flex-start;gap:10px;text-align:left}.online-purchase-status button{flex:0 0 auto}.customers-toolbar{align-items:stretch;flex-wrap:wrap}.customers-toolbar input{flex:1 1 520px}.hours-heading,.customers-heading,.online-purchases-heading{align-items:start}.internal-task-columns{grid-template-columns:1fr}.internal-task-column{padding:18px} +} + +/* Startseite Svenja: offene Punkte als ruhige, direkt nutzbare Übersicht. */ +.svenja-start-overview{margin:0 0 22px;padding:20px 22px;border:1px solid #34586a;border-radius:14px;background:linear-gradient(118deg,#17384b,#123244);box-shadow:0 12px 28px #071d2d36;color:#eff8fb}.svenja-start-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:16px}.svenja-start-heading h2{margin:6px 0 5px;color:#fff;font-size:24px;letter-spacing:-.035em}.svenja-start-heading p:not(.home-kicker){margin:0;color:#c3d8e2;font-size:13px}.svenja-start-heading>span{flex:0 0 auto;border:1px solid #9be97c8c;border-radius:999px;padding:8px 11px;background:#9be97c;color:#123a2a;font-size:12px;font-weight:950;white-space:nowrap}.svenja-start-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.svenja-start-grid button{display:grid;grid-template-columns:30px minmax(0,1fr) auto 12px;align-items:center;gap:10px;width:100%;min-height:68px;border:1px solid #496b7b;border-radius:10px;padding:12px 13px;background:#102b3b;color:#eff8fb;text-align:left;cursor:pointer;transition:border-color .16s,background .16s,transform .16s}.svenja-start-grid button:hover{border-color:#9be97c;background:#1a4150;transform:translateY(-1px)}.svenja-start-grid i{display:grid;place-items:center;width:30px;height:30px;border-radius:8px;background:#24536a;color:#9be97c;font-style:normal;font-size:16px}.svenja-start-grid span{display:grid;gap:3px;min-width:0}.svenja-start-grid b{overflow:hidden;color:#fff;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.svenja-start-grid small{overflow:hidden;color:#b8ced8;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.svenja-start-grid strong{display:grid;place-items:center;min-width:28px;height:28px;border-radius:50%;background:#edfbe9;color:#1a6232;font-size:12px}.svenja-start-grid em{color:#9be97c;font-size:18px;font-style:normal;font-weight:900}.svenja-start-empty{display:flex;align-items:center;gap:9px;padding:14px;border:1px dashed #507487;border-radius:10px;background:#102b3b;color:#c7dce5;font-size:13px}.svenja-start-empty b{color:#9be97c}.svenja-start-empty span{color:#b8ced8}@media(max-width:700px){.svenja-start-overview{padding:17px}.svenja-start-heading{align-items:start;flex-direction:column;gap:10px}.svenja-start-heading h2{font-size:21px}.svenja-start-grid{grid-template-columns:1fr}.svenja-start-heading>span{align-self:flex-start}} + +.customer-delete-confirm{position:absolute;z-index:1;inset:0;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:28px;border:1px solid #ff5b67;border-radius:14px;background:#111f2bef;box-shadow:0 0 0 9999px #0611198f}.customer-delete-confirm .home-kicker{color:#ff7781}.customer-delete-confirm h4{margin:0;color:#fff;font-size:24px}.customer-delete-confirm p{margin:0;color:#d9e7ee;font-size:14px;line-height:1.5}.customer-delete-confirm small{color:#b7c8d2;font-size:12px}.customer-delete-confirm>div{display:flex;justify-content:flex-end;gap:10px;margin-top:6px}.customer-delete-confirm button{border:1px solid #608091;border-radius:8px;padding:12px 15px;background:transparent;color:#dcecf3;font-weight:800;cursor:pointer}.customer-delete-confirm .customer-delete-confirm-button{border-color:#ff5b67;background:#9f1724;color:#fff;box-shadow:0 0 8px #ff3b4b,0 0 18px #9d1220}.customer-delete-confirm .customer-delete-confirm-button:hover{background:#c92233;box-shadow:0 0 12px #ff5b67,0 0 26px #c91f30}.customer-delete-confirm button:disabled{opacity:.65;cursor:wait} + +/* Kompakter Löschdialog */ +.customer-delete-confirm{position:fixed;z-index:30;inset:auto;top:50%;left:50%;width:min(430px,calc(100vw - 40px));padding:22px;transform:translate(-50%,-50%);border:1px solid #ff5b67;border-radius:12px;background:#172a36;box-shadow:0 18px 48px #000b}.customer-delete-confirm h4{font-size:20px}.customer-delete-confirm p{font-size:13px}.customer-delete-confirm small{font-size:11px}.customer-delete-confirm>div{margin-top:2px} + +.ticket-report-actions{display:flex;align-items:center;gap:8px;white-space:nowrap}.ticket-report-file{display:inline-flex;align-items:center;justify-content:center;border:1px solid #78b7cc;border-radius:7px;padding:7px 9px;background:#eef8fc;color:#174c64;font-size:10px;font-weight:900;text-decoration:none}.ticket-report-file:hover{border-color:#2f8744;background:#e2f6dc;color:#1d7032}:root[data-theme="dark"] .ticket-report-file{border-color:#4b7282;background:#183645;color:#d8edf5}:root[data-theme="dark"] .ticket-report-file:hover{border-color:#76c870;background:#244a38;color:#e4f8df} + +/* EK-DOS V2.0 – zentrale Arbeitsoberfläche */ +:root{--dash-bg:#080f17;--dash-panel:#101b28;--dash-panel-2:#0d1723;--dash-line:#26384a;--dash-text:#eff6fc;--dash-muted:#91a3b5;--dash-blue:#3ca7ff;--dash-orange:#ff9e45;--dash-violet:#af8bff;--dash-green:#5bd487} +:root[data-theme="dark"] body{background:var(--dash-bg)}.training-shell{background:var(--dash-bg)}.app-header{height:72px;border-bottom:1px solid #223446;background:#0b1520}.company{width:248px;border-right:1px solid #253a4b;background:#09131d}.company img{width:178px}.app-title{padding-left:20px}.app-title b{font-size:18px;letter-spacing:.02em}.app-title span{color:#9eb2c3;font-size:12px}.top-status-strip{display:grid;grid-template-columns:repeat(6,auto);align-items:stretch;align-self:stretch;margin-left:auto;border-left:1px solid #26384a}.top-status-strip span{display:flex;align-items:center;gap:7px;padding:0 12px;border-right:1px solid #26384a;color:#a4b6c6;font-size:10px;font-weight:800}.top-status-strip i{width:7px;height:7px;border-radius:50%;background:#54d982;box-shadow:0 0 9px #54d982}.session-user{background:#0c1721;border-left:1px solid #26384a}.app-body{grid-template-columns:256px minmax(0,1fr);min-height:calc(100vh - 72px)}.app-nav{padding:18px 12px;background:#0b1520;border-right:1px solid #26384a}.app-nav button{gap:12px;min-height:45px;margin:2px 0;padding:9px 12px;border:1px solid transparent;border-radius:10px;color:#c9d7e2;font-size:13px}.app-nav button:hover{background:#142332;border-color:#294354}.app-nav button>span:first-child{display:grid;place-items:center;width:30px;height:30px;border-radius:9px;background:#173a57;color:#62b7ff;font-size:16px;line-height:1}.app-nav button:nth-of-type(even)>span:first-child{background:#442e22;color:#ffad5d}.app-nav .selected{border-color:#315a7c;background:#152b3d;color:#f4f9fd;box-shadow:inset 3px 0 #4aafff;font-weight:800}.app-nav .selected>span:first-child{background:#247fc1;color:#fff}.nav-badge,.nav-count{min-width:21px;height:21px;background:#ff9e45;color:#211306;font-size:10px;box-shadow:0 0 0 2px #0b1520}.nav-chevron{color:#8ca7bb!important;background:transparent!important;font-size:17px!important}.nav-submenu{margin:1px 0 5px 39px;border-left:1px solid #314454}.nav-submenu button{min-height:34px;padding:6px 10px;border:0;border-radius:0;font-size:11px}.nav-role{margin:12px 0 0;border-top:1px solid #26394a}.nav-role>b{display:block;padding:13px 12px 5px;color:#70889a;font-size:10px;letter-spacing:.12em}.system{left:20px;right:20px;padding:16px 2px;background:#0b1520}.system>span{color:#5bd487}.system small{color:#849bac} +.dashboard-page{min-height:calc(100vh - 72px);padding:28px clamp(24px,3vw,52px) 18px;background:radial-gradient(circle at 76% -18%,#18385a55,transparent 38%),linear-gradient(135deg,#080f17 0%,#0c1621 100%);color:var(--dash-text)}.dashboard-welcome{display:flex;align-items:end;justify-content:space-between;gap:26px;margin-bottom:22px}.dashboard-welcome p,.dashboard-panel p{margin:0;color:#60b5ff;font-size:10px;font-weight:900;letter-spacing:.15em}.dashboard-welcome h1{margin:8px 0 6px;font-size:clamp(29px,3vw,42px);letter-spacing:-.04em}.dashboard-welcome>div>span{color:var(--dash-muted);font-size:13px;text-transform:capitalize}.dashboard-v2{display:grid;justify-items:end;gap:4px;border:1px solid #31506a;border-radius:10px;padding:10px 13px;background:#0d1b28}.dashboard-v2 b{color:#f5fbff;font-size:16px;letter-spacing:.06em}.dashboard-v2 span{color:#68bfff;font-size:9px;font-weight:800;letter-spacing:.1em}.dashboard-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:14px}.dashboard-kpis button{display:flex;align-items:center;gap:14px;min-height:111px;border:1px solid var(--dash-line);border-radius:13px;padding:18px;background:linear-gradient(145deg,#121f2d,#0d1722);color:var(--dash-text);text-align:left;cursor:pointer;transition:.17s}.dashboard-kpis button:hover{border-color:#4f81a7;transform:translateY(-2px);background:#152535}.kpi-icon{display:grid;place-items:center;width:46px;height:46px;border-radius:13px;font-size:21px;font-weight:900}.kpi-icon.blue{background:#173b58;color:#61baff}.kpi-icon.orange{background:#4c3321;color:#ffaf60}.kpi-icon.violet{background:#362852;color:#b89bff}.kpi-icon.green{background:#1a4637;color:#75e2a0}.dashboard-kpis small{display:block;color:#94aabd;font-size:10px;font-weight:900;letter-spacing:.08em}.dashboard-kpis b{display:block;margin:4px 0 2px;font-size:29px;letter-spacing:-.05em}.dashboard-kpis em{color:#8198a9;font-size:11px;font-style:normal}.dashboard-grid{display:grid;grid-template-columns:1.55fr 1.05fr 1fr;gap:14px}.dashboard-panel{border:1px solid var(--dash-line);border-radius:13px;padding:18px;background:linear-gradient(145deg,#111d2a,#0d1722);box-shadow:0 12px 28px #02070e2e}.dashboard-panel h2{margin:6px 0 0;color:#f0f6fb;font-size:18px;letter-spacing:-.025em}.panel-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.panel-heading>button,.helga-card button,.workflow-card button{border:0;background:transparent;color:#62b8ff;font-size:11px;font-weight:800;cursor:pointer}.dashboard-activity{grid-row:span 2}.activity-list{display:grid;margin-top:15px}.activity-list>button,.dashboard-inbox>button{display:grid;grid-template-columns:10px minmax(0,1fr) auto auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:13px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.activity-list>button:hover b,.dashboard-inbox>button:hover b{color:#65bbff}.activity-list b,.dashboard-inbox b{display:block;font-size:13px}.activity-list small,.dashboard-inbox small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.activity-list em,.dashboard-inbox em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.activity-list time{color:#718899;font-size:10px}.activity-dot{display:inline-block;width:8px;height:8px;border-radius:50%;box-shadow:0 0 8px currentColor}.activity-dot.blue{background:#4db5ff;color:#4db5ff}.activity-dot.orange{background:#ffa254;color:#ffa254}.activity-dot.green{background:#62d589;color:#62d589}.activity-empty{display:flex;align-items:center;gap:10px;margin-top:15px;border-top:1px solid #233748;padding:17px 0;color:#9bb0c0;font-size:12px}.helga-card{display:grid;grid-template-columns:103px 1fr;gap:17px;align-items:center;background:radial-gradient(circle at 20% 0,#20517b55,transparent 46%),linear-gradient(145deg,#10263a,#101925)}.helga-photo{display:grid;place-items:center;width:103px;height:123px;border:1px solid #5a9dcc;border-radius:12px;background:linear-gradient(145deg,#81c3e9,#24577a 48%,#102e49);box-shadow:inset 0 0 0 6px #ffffff11}.helga-photo span{display:grid;place-items:center;width:54px;height:54px;border-radius:50%;background:#f5d0b1;color:#73513d;font-size:26px;font-weight:900;box-shadow:0 14px 0 -4px #d5e8f2}.helga-card p{font-size:9px}.helga-card h2{font-size:24px}.helga-card b{display:block;margin:7px 0 4px;color:#81df9b;font-size:12px}.helga-card small{display:block;color:#a2b7c7;font-size:11px;line-height:1.4}.helga-card button{margin-top:10px;padding:0}.dashboard-inbox{display:grid;align-content:start}.dashboard-inbox>button{grid-template-columns:10px 1fr auto;padding:15px 0}.count-pill{display:grid;place-items:center;min-width:25px;height:25px;border-radius:50%;background:#284866;color:#bfe3ff;font-size:11px;font-weight:900}.dashboard-tasks{grid-column:2}.task-lines{display:grid;gap:12px;margin-top:16px}.task-lines span{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:8px;color:#c5d4df;font-size:12px}.task-lines b{color:#fff;font-size:13px}.calendar-card{display:grid;gap:16px;min-height:180px}.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:5px}.calendar-grid span{color:#70899d;font-size:9px;font-weight:800;text-align:center}.calendar-grid b{grid-column:5;display:grid;place-items:center;width:31px;height:31px;border-radius:50%;background:#3ca7ff;color:#fff;font-size:12px;box-shadow:0 0 14px #3ca7ff75}.calendar-card small{color:#8097a9;font-size:10px}.warnings-card{grid-column:2}.warnings-card>div:last-child{display:flex;align-items:center;gap:12px;margin-top:15px;padding-top:14px;border-top:1px solid #233748}.warnings-card>div:last-child i{display:grid;place-items:center;width:31px;height:31px;border-radius:9px;background:#193f32;color:#6de398;font-style:normal;font-weight:900}.warnings-card>div:last-child b,.warnings-card>div:last-child small{display:block}.warnings-card>div:last-child b{font-size:12px}.warnings-card>div:last-child small{margin-top:3px;color:#849baa;font-size:10px}.system-ok{color:#6bdf94;font-size:10px;font-weight:800}.workflow-card{grid-column:3;display:flex;align-items:center;justify-content:space-between;gap:12px;background:linear-gradient(115deg,#172b40,#101b27)}.workflow-card span{display:block;margin-top:9px;color:#9cb3c3;font-size:11px;line-height:1.4}.workflow-card button{flex:0 0 auto;border:1px solid #356080;border-radius:8px;padding:9px 11px}.dashboard-footer{display:flex;justify-content:space-between;gap:20px;margin-top:18px;border-top:1px solid #203344;padding:15px 3px 0;color:#6f8798;font-size:10px}.dashboard-footer b{color:#6add96}@media(max-width:1270px){.top-status-strip{display:none}.dashboard-grid{grid-template-columns:1.3fr 1fr}.dashboard-activity{grid-row:span 2}.dashboard-inbox{grid-column:2}.dashboard-tasks,.warnings-card{grid-column:1}.calendar-card{grid-column:2}.workflow-card{grid-column:1/-1}}@media(max-width:880px){.app-body{grid-template-columns:1fr}.app-nav{display:flex;overflow:auto;flex-direction:row;border-bottom:1px solid #26384a}.system{display:none}.dashboard-page{padding:21px 14px}.dashboard-welcome{align-items:start;flex-direction:column}.dashboard-v2{justify-items:start}.dashboard-kpis,.dashboard-grid{grid-template-columns:1fr}.dashboard-activity,.dashboard-inbox,.dashboard-tasks,.calendar-card,.warnings-card,.workflow-card{grid-column:auto;grid-row:auto}.dashboard-footer{align-items:start;flex-direction:column}.dashboard-kpis button{min-height:90px}.top-status-strip{display:none}}@media(max-width:520px){.dashboard-kpis{gap:9px}.dashboard-kpis button{gap:10px;padding:13px}.kpi-icon{width:39px;height:39px}.dashboard-kpis b{font-size:24px}.helga-card{grid-template-columns:80px 1fr}.helga-photo{width:80px;height:101px}.dashboard-panel{padding:15px}} +.internal-task-category:has(.internal-task-dot.heute_erledigen){border-color:#35576a;background:transparent;box-shadow:none}.internal-task-category:has(.internal-task-dot.heute_erledigen) h4,.internal-task-category:has(.internal-task-dot.heute_erledigen) h4>b,.internal-task-category:has(.internal-task-dot.heute_erledigen) .internal-task-item b,.internal-task-category:has(.internal-task-dot.heute_erledigen) .internal-task-item small,.internal-task-category:has(.internal-task-dot.heute_erledigen) summary{color:#ff5264;text-shadow:0 0 9px #ff263c99}.internal-task-category:has(.internal-task-dot.heute_erledigen) h4>b{background:transparent;box-shadow:none}.internal-task-category:has(.internal-task-dot.heute_erledigen) .internal-task-item{border-color:#294957;background:transparent;box-shadow:none}.online-purchases-page:has(.internal-task-dot.heute_erledigen):not(:has(.internal-task-dot.angebote)) .online-purchases-heading h2{color:#ff5264;text-shadow:0 0 10px #ff263c99}.app-nav>button:nth-of-type(6).selected + .nav-submenu button:last-child{color:#ff5264!important;text-shadow:0 0 8px #ff263c99;font-weight:900}.app-nav>button:nth-of-type(6).selected + .nav-submenu button:last-child .nav-badge{background:transparent;color:#ff5264;box-shadow:none} +.app-nav .nav-submenu button{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;width:100%;text-align:left}.app-nav .nav-submenu .nav-badge{justify-self:end;margin-left:12px} +.internal-task-item .online-purchase-status{display:flex!important;flex-flow:row nowrap;align-items:center;justify-content:flex-end;gap:8px;text-align:right}.internal-task-item .online-purchase-status>button,.internal-task-item .online-purchase-status>strong{display:inline-flex;align-items:center;justify-content:center;min-height:42px;margin:0;padding:9px 13px;white-space:nowrap}.internal-task-item .online-purchase-status .internal-task-edit{order:1}.internal-task-item .online-purchase-status>button:not(.internal-task-edit):not(.online-purchase-delete),.internal-task-item .online-purchase-status>strong{order:2}.internal-task-item .online-purchase-status .online-purchase-delete{order:3;border:1px solid #ff5264!important;background:#b91529!important;color:#fff!important;box-shadow:0 0 12px #ff263c99}.internal-task-item .online-purchase-status .online-purchase-delete:hover{background:#e3273d!important;box-shadow:0 0 17px #ff263ccc} +.nav-submenu-nested{margin:1px 0 5px 16px!important;padding-left:14px!important;border-left-color:#4c7185!important}.internal-task-single-column{grid-template-columns:minmax(0,1fr)}.app-nav .selected,.app-nav .nav-submenu button.active{font-weight:400} + +/* Kopfzeile und Navigation verwenden dieselbe Spaltenbreite: die Trennlinie bleibt dadurch lückenlos in einer Flucht. */ +@media (min-width:1281px){.company,.app-body{--ekdos-nav-width:256px}.company{width:var(--ekdos-nav-width);padding:0}.app-body{grid-template-columns:var(--ekdos-nav-width) minmax(0,1fr)}.company img{width:100%;height:100%;object-fit:contain}} +@media (min-width:801px) and (max-width:1280px){.company,.app-body{--ekdos-nav-width:210px}.company{width:var(--ekdos-nav-width);padding:0}.app-body{grid-template-columns:var(--ekdos-nav-width) minmax(0,1fr)}.company img{width:100%;height:100%;object-fit:contain}} + +/* Aufgaben im Kundenfenster */ +.customer-task-overview{display:grid;gap:12px;border-top:1px solid #385668;padding-top:18px}.customer-task-heading{display:flex;align-items:end;justify-content:space-between;gap:14px}.customer-task-heading .home-kicker{margin:0}.customer-task-heading h4{margin:5px 0 0;color:#edf7fc;font-size:16px}.customer-task-tabs{display:flex;flex-wrap:wrap;gap:6px}.customer-task-tabs button{border:1px solid #456879;border-radius:999px;padding:7px 9px;background:#102430;color:#c5d8e2;font-size:10px;font-weight:800;cursor:pointer}.customer-task-tabs button.active{border-color:#72c6f4;background:#1b4860;color:#fff}.customer-task-list{display:grid;gap:7px}.customer-task-list article{display:grid;grid-template-columns:28px minmax(0,1fr);gap:9px;align-items:center;border:1px solid #355565;border-radius:8px;padding:10px 11px;background:#102430}.customer-task-list article i{display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:#183d55;color:#72bdff;font-size:13px;font-style:normal}.customer-task-list article.done i{background:#1c4b39;color:#72db9e}.customer-task-list b{display:block;color:#edf7fc;font-size:12px;line-height:1.35}.customer-task-list small{display:block;margin-top:3px;color:#9eb7c5;font-size:10px}.customer-task-empty{margin:0;border:1px dashed #426274;border-radius:8px;padding:12px;color:#9eb7c5;font-size:12px}@media(max-width:700px){.customer-task-heading{align-items:start;flex-direction:column}.customer-task-tabs{width:100%}} + +.internal-task-form{grid-template-columns:minmax(220px,1fr) 160px minmax(210px,.7fr) auto}@media(max-width:1120px){.internal-task-form{grid-template-columns:minmax(220px,1fr) 160px minmax(190px,.7fr) auto}}@media(max-width:820px){.internal-task-form{grid-template-columns:1fr 1fr}.internal-task-form button{grid-column:1/-1}}@media(max-width:640px){.internal-task-form{grid-template-columns:1fr}.internal-task-form button{grid-column:auto}} +.internal-task-customer-search{display:grid;gap:4px}.internal-task-customer-search small{min-height:12px;color:#8fa8b7;font-size:9px;font-weight:400} + +.customer-task-list article.svenja{border-color:#7655a4;background:linear-gradient(100deg,#34234d,#171f2d)}.customer-task-list article.svenja i{background:#5d3a8a;color:#eedcff;box-shadow:0 0 10px #9a63df77}.customer-task-list article.sascha{border-color:#947122;background:linear-gradient(100deg,#483714,#1f2021)}.customer-task-list article.sascha i{background:#80611a;color:#fff0ae;box-shadow:0 0 10px #e3bf4477}.customer-task-list article.done.svenja{background:linear-gradient(100deg,#2c2840,#171f2d)}.customer-task-list article.done.sascha{background:linear-gradient(100deg,#3d351f,#1f2021)} + +.invoice-overview-tabs{display:flex;gap:8px;margin:0 0 16px}.invoice-overview-tabs button{border:1px solid #456879;border-radius:999px;padding:8px 12px;background:#102430;color:#c5d8e2;font-size:12px;font-weight:800;cursor:pointer}.invoice-overview-tabs button.active{border-color:#72c6f4;background:#1b4860;color:#fff}.invoice-overview-list{display:grid;gap:10px}.invoice-overview-item{display:grid;grid-template-columns:1.05fr 1.3fr 1.3fr .75fr auto;gap:14px;align-items:center;border:1px solid #355565;border-radius:10px;padding:14px;background:#112532;color:#deedf4}.invoice-overview-item small{display:block;margin-bottom:5px;color:#86a7b8;font-size:9px;font-weight:900;letter-spacing:.09em}.invoice-overview-item b,.invoice-overview-item span{font-size:12px}.invoice-overview-item a{justify-self:end;border:1px solid #5d91a8;border-radius:7px;padding:9px 10px;color:#d6e9f2;font-size:11px;font-weight:800;text-decoration:none}.invoice-assignment{grid-column:1/-1;display:grid;grid-template-columns:minmax(260px,1fr) auto;gap:9px;align-items:end;border-top:1px solid #365666;padding-top:12px}.invoice-assignment label{display:grid;gap:5px;color:#b7cad4;font-size:10px;font-weight:800}.invoice-assignment input{width:100%;min-height:39px;border:1px solid #4b6c7d;border-radius:7px;padding:9px 10px;background:#0c1d28;color:#edf7fb;font:inherit;font-size:12px}.invoice-assignment button{min-height:39px;border:0;border-radius:7px;padding:9px 13px;background:#9be97c;color:#10382d;font-size:11px;font-weight:900;cursor:pointer}.invoice-assignment button:disabled{opacity:.5;cursor:wait}@media(max-width:900px){.invoice-overview-item{grid-template-columns:repeat(2,minmax(0,1fr))}.invoice-overview-item a{justify-self:start}}@media(max-width:600px){.invoice-overview-item,.invoice-assignment{grid-template-columns:1fr}.invoice-overview-item a{width:max-content}} +.invoices-overview-page .invoice-overview-tabs{display:flex!important;align-items:center!important;gap:10px!important;margin:0 0 16px!important}.invoices-overview-page .invoice-overview-tabs button{display:inline-flex!important;align-items:center!important;justify-content:center!important;flex:0 0 auto!important;width:168px!important;height:46px!important;min-height:46px!important;margin:0!important;padding:0 14px!important;border:1px solid #456879!important;border-radius:9px!important;background:#102430!important;color:#c5d8e2!important;font-size:12px!important;font-weight:800!important;line-height:1.2!important}.invoices-overview-page .invoice-overview-tabs button.active{border-color:#72c6f4!important;background:#1b4860!important;color:#fff!important} +.internal-task-item .internal-task-check{display:grid;place-items:center;width:28px;height:28px;color:#72bdff;font-size:23px;font-weight:900;line-height:1;text-shadow:0 0 9px #3ea9f077}.internal-task-item .internal-task-check.done{color:#9be97c;text-shadow:0 0 10px #86e66599} +.internal-task-item{grid-template-columns:28px minmax(280px,1fr) minmax(190px,.5fr) auto}.internal-task-item .internal-task-details{display:grid;gap:3px}.internal-task-item .internal-task-customer-column{display:grid;gap:3px;min-width:0;padding-left:14px;border-left:1px solid #365666}.internal-task-item .internal-task-customer-column small{color:#78bfee;font-size:9px;font-weight:900;letter-spacing:.08em}.internal-task-item .internal-task-customer-column b{overflow:hidden;color:#cbe7f8;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:900px){.internal-task-item{grid-template-columns:28px minmax(0,1fr) auto}.internal-task-item .internal-task-customer-column{grid-column:2;padding-left:0;border-left:0}} diff --git a/frontend/src/styles/users.css b/frontend/src/styles/users.css new file mode 100644 index 0000000..80487bc --- /dev/null +++ b/frontend/src/styles/users.css @@ -0,0 +1,286 @@ +/* Benutzerverwaltung. Setzt auf den vorhandenen Listenstil der Ticketansicht auf + und ergaenzt nur, was es dort noch nicht gibt: Formular, Rollenchips, Protokoll. */ + +.users-notice { + max-width: 720px; + margin: 0 0 18px; + padding: 14px 18px; + border: 1px solid #9fd6a4; + border-radius: 10px; + background: #f1fbef; + color: #216c2c; + font-size: 13px; +} + +.users-form { + margin: 0 0 22px; + padding: 22px 24px; + border: 1px solid #c7dce8; + border-radius: 12px; + background: #fff; + box-shadow: 0 10px 24px #294f670e; +} + +.users-form h3 { + margin: 0 0 16px; + font-size: 18px; + letter-spacing: -0.03em; +} + +.users-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px 18px; +} + +.users-form label { + display: grid; + gap: 6px; + color: #3d596d; + font-size: 12px; + font-weight: 800; +} + +.users-form input, +.users-form select { + width: 100%; + border: 1px solid #b6cfdd; + border-radius: 8px; + padding: 11px 12px; + background: #fbfdff; + color: #17384e; + font: inherit; + outline: none; +} + +.users-form input:focus, +.users-form select:focus { + border-color: #5fb85f; + box-shadow: 0 0 0 3px #9be97c33; +} + +.users-form input:disabled, +.users-form select:disabled { + background: #eef3f6; + color: #6b8091; + cursor: not-allowed; +} + +.users-form label small { + color: #6a8290; + font-size: 11px; + font-weight: 400; +} + +.users-form > button, +.users-form-actions button { + margin-top: 18px; + border: 0; + border-radius: 8px; + padding: 12px 18px; + background: #2f8744; + color: #fff; + font-size: 13px; + font-weight: 800; + cursor: pointer; +} + +.users-form > button:disabled, +.users-form-actions button:disabled { + opacity: 0.62; + cursor: wait; +} + +.users-form-actions { + display: flex; + gap: 10px; +} + +.users-form-actions .users-secondary { + background: #fff; + border: 1px solid #9fbedb; + color: #1b3047; +} + +.users-permissions { + margin-top: 16px; + padding: 12px 14px; + border-radius: 8px; + background: #f2f9fd; + color: #3d596d; + font-size: 12px; +} + +.users-permissions b { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.users-permissions ul { + margin: 0; + padding-left: 18px; + line-height: 1.6; +} + +/* Rollen als farbige Chips, damit die Tabelle auf einen Blick lesbar ist. */ +.users-role-admin { + background: #ffe9c9; + color: #8a5411; +} + +.users-role-inhaber { + background: #dbe7ff; + color: #24478f; +} + +.users-role-buero { + background: #e6f7dd; + color: #25753a; +} + +.users-state-active { + color: #25753a; + font-weight: 800; +} + +.users-state-inactive { + color: #a82028; + font-weight: 800; +} + +.users-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.users-actions button { + border: 1px solid #9fbedb; + border-radius: 6px; + padding: 7px 10px; + background: #fff; + color: #1b3047; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.users-actions button:hover:not(:disabled) { + border-color: #6cc55a; + background: #f1fbf3; +} + +.users-actions button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.users-actions .users-danger { + border-color: #e0a9a4; + color: #a1332d; +} + +.users-actions .users-danger:hover:not(:disabled) { + border-color: #d66b6b; + background: #fff4f3; +} + +.users-audit { + margin-top: 26px; + padding: 20px 22px; + border: 1px solid #c7dce8; + border-radius: 12px; + background: #fff; +} + +.users-audit h3 { + margin: 0 0 12px; + font-size: 15px; +} + +.users-audit ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; +} + +.users-audit li { + display: flex; + gap: 14px; + align-items: baseline; + padding-bottom: 8px; + border-bottom: 1px solid #e6eef3; + font-size: 12px; +} + +.users-audit li:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.users-audit b { + flex: 0 0 150px; + color: #3d596d; + font-size: 11px; +} + +.users-audit span { + color: #244258; +} + +@media (max-width: 800px) { + .users-audit li { + flex-direction: column; + gap: 2px; + } + + .users-audit b { + flex: none; + } +} + +/* Dunkles Farbschema, passend zum Rest der Anwendung. */ +:root[data-theme='dark'] .users-form, +:root[data-theme='dark'] .users-audit { + border-color: #34505f; + background: #172a36; + color: #e5eff5; +} + +:root[data-theme='dark'] .users-form input, +:root[data-theme='dark'] .users-form select { + border-color: #3a5868; + background: #132630; + color: #e3eff5; +} + +:root[data-theme='dark'] .users-form label, +:root[data-theme='dark'] .users-permissions, +:root[data-theme='dark'] .users-audit span { + color: #b4c5cf; +} + +:root[data-theme='dark'] .users-permissions { + background: #1d3544; +} + +:root[data-theme='dark'] .users-notice { + border-color: #39704a; + background: #163023; + color: #d7f3d9; +} + +:root[data-theme='dark'] .users-actions button { + border-color: #3a5868; + background: #132630; + color: #dceaf2; +} + +:root[data-theme='dark'] .users-audit li { + border-color: #294553; +} diff --git a/frontend/src/views/Dashboard.tsx b/frontend/src/views/Dashboard.tsx new file mode 100644 index 0000000..7e9b9e7 --- /dev/null +++ b/frontend/src/views/Dashboard.tsx @@ -0,0 +1,4782 @@ +import { Fragment, useEffect, useState, type FormEvent } from "react"; +import { EKDOS_VERSION } from "@/lib/version"; +import { useAuth } from "@/auth/AuthProvider"; +import { Permission } from "@/auth/types"; +import { UsersView } from "@/views/UsersView"; + +type Stage = { + title: string; + question: string; + task: string; + helga: string; + active: string[]; +}; + +type OpenTicket = { + id: string; + status: string; + reportStatus?: string; + customer: string; + property: string; + title: string; + updatedAt: string; + continuation?: string; + decisionCode?: string; + consultationResult?: string; + consultationUpdatedAt?: string; + consultationBy?: string; + requiresCustomerConsultation?: boolean; + createdAt?: string; + technician?: string; + reportCount?: number; +}; + +type HourEntry = { + date: string; + label: string; + status: string; + customer?: string; + property?: string; + report?: string; + hours: number; +}; +type HourEmployee = { + name: string; + totalHours: number; + entries: HourEntry[]; + statusDays?: { school: number; sick: number; vacation: number }; + workDaysYear?: number; +}; +type HoursOverview = { + month: string; + year: number; + generatedAt: string; + employees: HourEmployee[]; + statusPriority?: string; +}; +type InvoiceReview = { + id: string; + title: string; + createdAt: string; + body: string; + status?: string; + completedAt?: string; +}; +type InvoiceSendTask = { + id: string | number; + versand_id?: string; + ticketnummer?: string; + kunde?: string; + liegenschaft?: string; + rechnungsnummer?: string; + created_at?: string; + versendet_am?: string; +}; +type Customer = { + id: string; + kundennummer_bestand?: string; + firma?: string; + anrede?: string; + name?: string; + strasse?: string; + plz?: string; + ort?: string; + telefon?: string; + mobil?: string; + email?: string; + notiz?: string; + aktualisiert_am?: string; +}; +type OnlinePurchase = { + id: string; + artikel: string; + kaufort: string; + kaufdatum: string; + status?: string; + angelegt_von?: string; + angelegt_am?: string; + erledigt_von?: string; + erledigt_am?: string; +}; +type InternalTask = { + id: string; + aufgabe: string; + empfaenger?: string; + kategorie?: string; + kunden_id?: string; + status?: string; + erstellt_von?: string; + erstellt_am?: string; + erledigt_von?: string; + erledigt_am?: string; +}; +type Offer = { + id: string; + dateiname: string; + dateipfad?: string; + angebot_nummer?: string; + erstellt_am?: string; + versendet?: boolean; + versandart?: string; + versendet_am?: string; + status?: string; + beauftragt_am?: string; + gueltig_bis?: string; + kunde?: string; + objekt_zusatz?: string; +}; +type CustomerInvoice = { + id?: string; + rechnungsnummer?: string; + dateiname?: string; + dateipfad?: string; + kunde?: string; + liegenschaft?: string; + rechnungsdatum?: string; +}; +type InvoiceProperty = { liegenschaft: string; count: number }; + +type WorkspaceArea = { title: string; kicker: string; description: string }; + +const workspaceAreas: Record = { + "offers-all": { + title: "Erstellte Angebote", + kicker: "ANGEBOTE", + description: "Hier erscheinen neu erstellte Angebote aus EK-DOS.", + }, + "offers-commissioned": { + title: "Beauftragte Angebote", + kicker: "ANGEBOTE", + description: "Hier werden beauftragte Angebote dargestellt.", + }, + "offers-open": { + title: "Offene Angebote", + kicker: "ANGEBOTE", + description: "Hier werden noch offene Angebote dargestellt.", + }, + "offers-expired": { + title: "Abgelaufene Angebote", + kicker: "ANGEBOTE", + description: + "Hier werden nicht beauftragte Angebote nach Ablauf ihrer Gültigkeit dargestellt.", + }, + "unit-prices": { + title: "Einheitspreise", + kicker: "KALKULATION", + description: + "Hier werden die Einheitspreise für EK-DOS bereitgestellt und gepflegt.", + }, + "time-records": { + title: "Stundennachweise", + kicker: "MITARBEITER", + description: + "Hier werden die Stundennachweise der Mitarbeiter bereitgestellt.", + }, + "internal-tasks": { + title: "Interne Aufgaben", + kicker: "ELEKTRO KRÜGER", + description: + "Hier werden gemeinsame interne Aufgaben für Svenja und Sascha zusammengeführt.", + }, + "invoice-review-svenja": { + title: "Rechnungen in Prüfung", + kicker: "SVENJA · RECHNUNGEN", + description: "Hier bearbeitet Svenja Rechnungen in der Prüfung.", + }, + "invoices-send": { + title: "Rechnungen zum Versenden", + kicker: "RECHNUNGEN", + description: + "Hier stehen freigegebene Rechnungen bereit, die noch versendet werden müssen.", + }, + "invoices-create": { + title: "Rechnungen anfertigen", + kicker: "SVENJA · RECHNUNGEN", + description: "Hier erstellt Svenja die vorbereiteten Rechnungen.", + }, + "invoices-created": { + title: "Erstellte Rechnungen", + kicker: "SVENJA · RECHNUNGEN", + description: + "Hier bleiben die bereits erstellten Rechnungen dauerhaft sichtbar.", + }, + "online-purchases": { + title: "Online Käufe", + kicker: "SVENJA", + description: "Hier bearbeitet Svenja die zugeordneten Online-Käufe.", + }, + "online-purchases-new": { + title: "Neue Online Käufe", + kicker: "SASCHA", + description: "Hier prüft Sascha neu eingegangene Online-Käufe.", + }, + "invoices-review-sascha": { + title: "Rechnungen prüfen", + kicker: "SASCHA · RECHNUNGEN", + description: "Hier prüft und gibt Sascha Rechnungen frei.", + }, + postcalculation: { + title: "Nachkalkulation", + kicker: "SASCHA", + description: "Hier stehen Aufträge für die Nachkalkulation bereit.", + }, + analytics: { + title: "Auswertungen", + kicker: "SASCHA", + description: "Hier werden die EK-DOS-Auswertungen bereitgestellt.", + }, + cashflow: { + title: "Cashflow", + kicker: "SASCHA · AUSWERTUNGEN", + description: + "Hier wird der aktuelle Cashflow von Elektro Krüger ausgewertet.", + }, +}; + +function formatDateTime(value?: string) { + if (!value) return "–"; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function formatDate(value?: string) { + if (!value) return "–"; + const date = new Date(`${value.slice(0, 10)}T12:00:00`); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date); +} + +function hoursStatusClass(status: string) { + const normalized = status.trim().toLocaleLowerCase("de-DE"); + if (normalized === "krank") return "sick"; + if (normalized === "urlaub") return "vacation"; + if (normalized.startsWith("berufsschul")) return "school"; + return "work"; +} + +function reportStatusClass(status?: string) { + const normalized = String(status ?? "") + .trim() + .toLocaleUpperCase("de-DE"); + if (normalized === "TEILERLEDIGT") return "ticket-report-status-partial"; + if (normalized === "ABGESCHLOSSEN") return "ticket-report-status-completed"; + return ""; +} + +function invoiceTicketNumber(task: InvoiceReview) { + const match = `${task.title ?? ""}\n${task.body ?? ""}`.match( + /(?:EK-DOS-TICKET|Ticket)\s*:\s*(\d{4}-\d+)/i, + ); + return match?.[1] ?? "–"; +} + +function invoiceReviewFields(task: InvoiceReview) { + const text = `${task.title ?? ""}\n${task.body ?? ""}` + .replace(/\s+/g, " ") + .trim(); + const field = (label: string, following: string[]) => { + const boundary = `(?=\\s*(?:${following.join("|")})\\s*:|$)`; + return ( + text + .match(new RegExp(`${label}\\s*:\\s*(.*?)${boundary}`, "i"))?.[1] + ?.trim() || "" + ); + }; + const kunde = + field("Kunde", [ + "Liegenschaft", + "Ticket", + "EK-DOS-TICKET", + "Digitale Akte", + ]) || + task.title + .replace(/^Rechnung\s+prüfen\s*[–-]\s*/i, "") + .replace(/\s*[–-]\s*Ticket\s*:\s*\d{4}-\d+.*$/i, "") + .trim(); + const liegenschaft = field("Liegenschaft", [ + "Ticket", + "EK-DOS-TICKET", + "Digitale Akte", + ]); + const strasseMitHausnummer = liegenschaft + .match(/^(.+?\s+\d+[a-zA-Z]?)\b/)?.[1] + ?.trim(); + return { + kunde: kunde || "–", + liegenschaft: strasseMitHausnummer || liegenschaft || "–", + ticket: invoiceTicketNumber(task), + }; +} + +const stages: Stage[] = [ + { + title: "Eingang und Zuordnung", + question: + "Workflow 1 bis 4: Der Servicebericht trifft ein, wird ausgelesen und Kunde, Liegenschaft sowie Ticket werden geprüft.", + task: "Keine Aktion, solange Helga keine Rückfrage stellt. Die Verarbeitung von E-Mail und PDF läuft automatisch.", + helga: + "Ich lese den Servicebericht aus, prüfe Kunde und Ticket und lege bei Bedarf den neuen Kunden oder ein neues Ticket an.", + active: ["mail", "pdf", "kunde", "ticketCheck", "ticketNew"], + }, + { + title: "Ticket und Status", + question: + "Workflow 4A und 4B: Abhängig davon, ob ein Ticket vorhanden ist, wird der Bericht dem richtigen Vorgang zugeordnet und der Status geprüft.", + task: "Bei einer Helga-Rückfrage nur die fehlende Zuordnung oder Information beantworten.", + helga: + "Ich speichere den Bericht im Ticket, lege den Ticketordner der Digitalen Akte an und prüfe: teilerledigt oder abgeschlossen?", + active: ["ticketNew", "ticketStore", "folder", "status"], + }, + { + title: "Teilerledigung", + question: + "Workflow 5A, 7, 9, 11 und 12: Ein teilerledigter Bericht bleibt im bestehenden Ticket offen und wird in Akte, NAS, Tagesübersicht und Stundenliste ergänzt.", + task: "Die Teams-Rückfrage von Helga beantworten, wenn Material, Termin, Rücksprache mit Kunde oder Sascha betroffen ist.", + helga: + "Ich sichere den Bericht, aktualisiere die Digitale Akte und frage dich nur, wenn für die weitere Bearbeitung eine Entscheidung nötig ist.", + active: ["partial", "nas", "akte", "daily", "hours", "teams"], + }, + { + title: "Abschluss, Rechnung und Versand", + question: + "Workflow 5B bis 18: Der Abschlussbericht schließt den Auftrag. Danach folgen Rechnungsaufgabe, Prüfung durch Sascha, Versandbestätigung und die abschließende Dokumentation.", + task: "Rechnung erstellen, nach Saschas Freigabe versenden und den Versand in Teams oder To Do bestätigen.", + helga: + "Ich übergebe die Rechnung an Sascha zur Prüfung und dokumentiere nach deiner Versandbestätigung Ticket, Digitale Akte und Jahresübersicht.", + active: [ + "completed", + "close", + "todo", + "invoice", + "review", + "send", + "archive", + ], + }, +]; + +const nodes = [ + ["mail", "Workflow 1\nServicebericht: serviceberichte@…", "green"], + ["pdf", "Workflow 2\nPDF auslesen und Felder speichern", "green"], + ["kunde", "Workflow 3\nKunde neu? Kundendaten anlegen", "yellow"], + ["ticketCheck", "Workflow 4\nTicket schon vorhanden?", "yellow"], + ["ticketNew", "Workflow 4A\nTicket anlegen / Bericht speichern", "yellow"], + ["folder", "Workflow 6\nDigitale Akte im Ticketordner erzeugen", "yellow"], + ["status", "Workflow 4A / 4B\nTeilerledigt oder abgeschlossen?", "yellow"], + ["partial", "Workflow 5A\nTeilerledigt", "yellow"], + ["nas", "Workflow 5A / 5B\nServicebericht auf NAS speichern", "yellow"], + ["akte", "Workflow 7\nDigitale Akte aktualisieren", "yellow"], + ["daily", "Workflow 9 / 13\nTageszusammenfassung ergänzen", "yellow"], + ["hours", "Workflow 11\nMitarbeiterstunden eintragen", "yellow"], + ["teams", "Workflow 12\nHelga fragt Svenja in Teams", "yellow"], + ["completed", "Workflow 5B / 8\nAbgeschlossen / Bericht beendet", "yellow"], + ["close", "Workflow 10\nTo Do an Svenja übertragen", "yellow"], + ["todo", "Workflow 15\nAufgabe „Rechnung erstellen“", "yellow"], + ["invoice", "Svenja erstellt Rechnung\nund schließt die Aufgabe", "yellow"], + ["review", "Workflow 15 / 16\nSascha prüft / Helga informiert", "yellow"], + ["send", "Workflow 17\nVersand in Teams / To Do bestätigen", "yellow"], + [ + "archive", + "Workflow 18\nDigitale Akte und Jahresübersicht aktualisieren", + "yellow", + ], +]; + +const exercises = [ + { + question: + "Praxisfall: Der von Workflow 2 ausgelesene Kunde ist noch nicht in den Kundendaten vorhanden. Was passiert als Nächstes?", + answers: [ + "Workflow 3 legt Kunde und Kundendaten neu an; anschließend prüft Workflow 4 das Ticket.", + "Svenja erstellt sofort eine Rechnung.", + "Der Servicebericht wird verworfen und muss neu gesendet werden.", + ], + correct: 0, + note: "Richtig. Die Neuanlage des Kunden ist automatisiert; erst danach wird die Ticketlage beurteilt.", + }, + { + question: + "Praxisfall: Zu Kunde und Liegenschaft gibt es bereits ein offenes Ticket. Welche Verzweigung ist richtig?", + answers: [ + "Workflow 4B: Den Bericht dem bestehenden Ticket zuordnen und danach den Status prüfen.", + "Workflow 4A: Immer ein zusätzliches Ticket eröffnen.", + "Die Digitale Akte wird erst nach dem Rechnungsversand angelegt.", + ], + correct: 0, + note: "Richtig. Ein bestehendes offenes Ticket wird ergänzt – doppelte Tickets werden vermieden.", + }, + { + question: + "Praxisfall: Der Bericht ist als „Teilerledigt“ gekennzeichnet. Was ist für Svenja richtig?", + answers: [ + "Die Helga-Rückfrage in Teams beantworten, falls Termin, Material oder Rücksprache erforderlich ist. Keine Rechnung erstellen.", + "Die Aufgabe „Rechnung erstellen“ abschließen.", + "Den Auftrag als abgeschlossen markieren.", + ], + correct: 0, + note: "Richtig. Das Ticket bleibt offen. Workflow 5A sichert und dokumentiert den Bericht, aber löst noch keine Rechnung aus.", + }, + { + question: + "Praxisfall: Der Abschlussbericht ist verarbeitet und Sascha hat die Rechnung freigegeben. Was fehlt noch?", + answers: [ + "Svenja versendet die Rechnung und bestätigt den Versand in Teams oder To Do.", + "Ein neues Ticket zum gleichen Auftrag anlegen.", + "Die Stunden aus der Mitarbeiter-Tabelle löschen.", + ], + correct: 0, + note: "Richtig. Die Versandbestätigung löst Workflow 18 aus: Digitale Akte und Jahresübersicht erhalten das Versanddatum.", + }, +]; + +function FlowNode({ id, activeIds }: { id: string; activeIds: string[] }) { + const node = nodes.find(([nodeId]) => nodeId === id); + if (!node) return null; + const [, label, tone] = node; + return ( +
+ + {label.split("\n").map((line, index) => ( + <> + {index > 0 &&
} + {line} + + ))} +
+
+ ); +} + +export default function Home() { + const [view, setView] = useState("home"); + const [offersOpen, setOffersOpen] = useState(false); + const [invoiceCreateOpen, setInvoiceCreateOpen] = useState(false); + const [analyticsOpen, setAnalyticsOpen] = useState(false); + const [stage, setStage] = useState(0); + const [answers, setAnswers] = useState>({}); + // Anmeldung, Sitzungsverlängerung und Leerlaufabmeldung liegen im AuthProvider. + // Hier steht nur noch, wer angemeldet ist und was diese Person darf. + const { user: currentUser, signOut, can } = useAuth(); + const user = currentUser?.name ?? null; + const [tickets, setTickets] = useState([]); + const [ticketsLoading, setTicketsLoading] = useState(false); + const [ticketsError, setTicketsError] = useState(""); + const [ticketsRefreshedAt, setTicketsRefreshedAt] = useState(""); + const [consultationDrafts, setConsultationDrafts] = useState< + Record + >({}); + const [consultationSaving, setConsultationSaving] = useState( + null, + ); + const [consultationError, setConsultationError] = useState< + Record + >({}); + const [hoursOverview, setHoursOverview] = useState( + null, + ); + const [hoursLoading, setHoursLoading] = useState(false); + const [hoursError, setHoursError] = useState(""); + const [invoiceReviews, setInvoiceReviews] = useState([]); + const [invoiceReviewsLoading, setInvoiceReviewsLoading] = useState(false); + const [invoiceReviewsError, setInvoiceReviewsError] = useState(""); + const [invoiceReviewsRefreshedAt, setInvoiceReviewsRefreshedAt] = + useState(""); + const [invoiceReviewCompleting, setInvoiceReviewCompleting] = useState< + string | null + >(null); + const [invoiceCreateTasks, setInvoiceCreateTasks] = useState( + [], + ); + const [invoiceCreateLoading, setInvoiceCreateLoading] = useState(false); + const [invoiceCreateError, setInvoiceCreateError] = useState(""); + const [invoiceCreateRefreshedAt, setInvoiceCreateRefreshedAt] = useState(""); + const [invoiceCreateCompleting, setInvoiceCreateCompleting] = useState< + string | null + >(null); + const openInvoiceCreateTasks = invoiceCreateTasks.filter( + (task) => task.status !== "erledigt", + ); + const completedInvoiceCreateTasks = invoiceCreateTasks.filter( + (task) => task.status === "erledigt", + ); + const [invoiceSendTasks, setInvoiceSendTasks] = useState( + [], + ); + const [invoiceSendLoading, setInvoiceSendLoading] = useState(false); + const [invoiceSendError, setInvoiceSendError] = useState(""); + const [invoiceSendRefreshedAt, setInvoiceSendRefreshedAt] = useState(""); + const [customers, setCustomers] = useState([]); + const [customersLoading, setCustomersLoading] = useState(false); + const [customersError, setCustomersError] = useState(""); + const [customerSearch, setCustomerSearch] = useState(""); + const [customerSort, setCustomerSort] = useState< + "name-asc" | "name-desc" | "number-asc" | "number-desc" + >("name-asc"); + const [editingCustomer, setEditingCustomer] = useState(null); + const [creatingCustomer, setCreatingCustomer] = useState(false); + const [customerTaskFilter, setCustomerTaskFilter] = useState< + "all" | "open" | "done" + >("all"); + const [customerSaving, setCustomerSaving] = useState(false); + const [customerDeleteConfirming, setCustomerDeleteConfirming] = + useState(false); + const [invoiceCustomer, setInvoiceCustomer] = useState(null); + const [customerInvoicesLoading, setCustomerInvoicesLoading] = useState(false); + const [invoiceProperties, setInvoiceProperties] = useState( + [], + ); + const [propertyInvoices, setPropertyInvoices] = useState< + Record + >({}); + const [propertyInvoiceLoading, setPropertyInvoiceLoading] = useState< + string | null + >(null); + const [propertyInvoiceError, setPropertyInvoiceError] = useState< + Record + >({}); + const [invoiceOverview, setInvoiceOverview] = useState([]); + const [invoiceOverviewLoading, setInvoiceOverviewLoading] = useState(false); + const [invoiceOverviewError, setInvoiceOverviewError] = useState(""); + const [invoiceOverviewTab, setInvoiceOverviewTab] = useState< + "all" | "unassigned" + >("all"); + const [invoiceAssignmentSearch, setInvoiceAssignmentSearch] = useState< + Record + >({}); + const [invoiceAssignmentBusy, setInvoiceAssignmentBusy] = useState< + string | null + >(null); + const [onlinePurchases, setOnlinePurchases] = useState([]); + const [onlinePurchasesLoading, setOnlinePurchasesLoading] = useState(false); + const [onlinePurchasesError, setOnlinePurchasesError] = useState(""); + const [onlinePurchaseSaving, setOnlinePurchaseSaving] = useState(false); + const [onlinePurchaseCompleting, setOnlinePurchaseCompleting] = useState< + string | null + >(null); + const [onlinePurchaseDeleting, setOnlinePurchaseDeleting] = useState< + string | null + >(null); + const [onlinePurchaseForm, setOnlinePurchaseForm] = useState({ + artikel: "", + kaufort: "", + kaufdatum: new Date().toISOString().slice(0, 10), + }); + const [internalTasks, setInternalTasks] = useState([]); + const [internalTaskDrafts, setInternalTaskDrafts] = useState< + Record + >({}); + const [internalTaskCustomerSearch, setInternalTaskCustomerSearch] = useState< + Record + >({}); + const [ + editingInternalTaskCustomerSearch, + setEditingInternalTaskCustomerSearch, + ] = useState(""); + const [editingInternalTaskCustomerId, setEditingInternalTaskCustomerId] = + useState(""); + const [internalTaskError, setInternalTaskError] = useState(""); + const [internalTaskBusy, setInternalTaskBusy] = useState(null); + const [internalTasksOpen, setInternalTasksOpen] = useState(false); + const [saschaTasksOpen, setSaschaTasksOpen] = useState(false); + const [internalTaskFilter, setInternalTaskFilter] = useState( + null, + ); + const [internalTaskRecipientFilter, setInternalTaskRecipientFilter] = + useState<"sascha" | "svenja">("svenja"); + const [editingInternalTask, setEditingInternalTask] = + useState(null); + const [offers, setOffers] = useState([]); + const [offersLoading, setOffersLoading] = useState(false); + const [offersError, setOffersError] = useState(""); + const [offerBusy, setOfferBusy] = useState(null); + const [offerShipping, setOfferShipping] = useState< + Record + >({}); + const [offerSearch, setOfferSearch] = useState(""); + const [offerConfirming, setOfferConfirming] = useState(null); + const [dashboardNow, setDashboardNow] = useState(() => new Date()); + const current = stages[stage]; + const exercise = exercises[stage]; + const workspace = workspaceAreas[view]; + // Früher wurde hier auf den Namen geprüft. Jetzt entscheidet das Recht, damit + // ein drittes Konto dieselben Ansichten bekommen kann, ohne dass hier etwas + // angefasst werden muss. Die Rolle "Büro" trägt Svenjas Rechte, "Inhaber" die + // von Sascha. + const isSvenja = can(Permission.InvoicesProcess); + const isSascha = can(Permission.PurchasesCreate); + const canManageUsers = currentUser?.canManageUsers ?? false; + const visibleInvoiceReviews = invoiceReviews.filter( + (invoice) => invoice.status !== "erledigt", + ); + const internalTaskRecipient = (task: InternalTask) => + task.empfaenger === "sascha" || task.empfaenger === "svenja" + ? task.empfaenger + : task.kategorie === "svenja" + ? "svenja" + : "sascha"; + const internalTaskCategory = (task: InternalTask) => { + if ( + [ + "angebote", + "steuerberater", + "kundenruecksprache", + "interne_bueroaufgaben", + "heute_erledigen", + ].includes(task.kategorie ?? "") + ) + return task.kategorie as + | "angebote" + | "steuerberater" + | "kundenruecksprache" + | "interne_bueroaufgaben" + | "heute_erledigen"; + return /angebot/i.test(task.aufgabe) ? "angebote" : "interne_bueroaufgaben"; + }; + const openTaskCount = (empfaenger: string) => + internalTasks.filter( + (task) => + task.status !== "erledigt" && + internalTaskRecipient(task) === empfaenger, + ).length; + const internalTaskCategories = [ + ["angebote", "Angebote"], + ["steuerberater", "Steuerberater"], + ["kundenruecksprache", "Rücksprache mit Kunde"], + ["interne_bueroaufgaben", "Interne Büroaufgaben"], + ["heute_erledigen", "WICHTIG! NOCH HEUTE ERLEDIGEN"], + ] as const; + const openTaskCategoryCount = ( + kategorie: string, + empfaenger = isSascha ? "sascha" : "svenja", + ) => + internalTasks.filter( + (task) => + task.status !== "erledigt" && + internalTaskRecipient(task) === empfaenger && + (kategorie === "interne_bueroaufgaben" + ? ["interne_bueroaufgaben", "kundenruecksprache"].includes( + internalTaskCategory(task), + ) + : internalTaskCategory(task) === kategorie), + ).length; + const activeInternalTaskRecipient = isSvenja + ? internalTaskRecipientFilter + : "sascha"; + const activeInternalTaskCategories = + activeInternalTaskRecipient === "sascha" + ? ([ + ["angebote", "Saschas Angebote"], + ["steuerberater", "Saschas Steuerberater"], + ["interne_bueroaufgaben", "Saschas Büroaufgaben"], + ["heute_erledigen", "Saschas WICHTIG!"], + ] as const) + : internalTaskCategories; + const customerTaskName = (customer: Customer) => { + const person = (customer.name ?? "").trim(); + const parts = person.split(/\s+/).filter(Boolean); + const orderedPerson = + parts.length > 1 + ? `${parts.at(-1)}, ${parts.slice(0, -1).join(" ")}` + : person; + return customer.firma + ? `${customer.firma}${orderedPerson ? ` · ${orderedPerson}` : ""}` + : orderedPerson || "Unbenannter Kunde"; + }; + const customerTaskOptions = customers + .map((customer) => ({ + customer, + label: customerTaskName(customer), + search: + `${customerTaskName(customer)} ${customer.name ?? ""} ${customer.firma ?? ""}`.toLocaleLowerCase( + "de-DE", + ), + })) + .sort((a, b) => a.label.localeCompare(b.label, "de")); + const internalTaskCustomerName = (task: InternalTask) => + customerTaskOptions.find( + (option) => String(option.customer.id) === String(task.kunden_id ?? ""), + )?.label ?? ""; + const openOnlinePurchaseCount = onlinePurchases.filter( + (purchase) => purchase.status?.toLocaleLowerCase("de-DE") !== "erledigt", + ).length; + const offerExpired = (offer: Offer) => + offer.status !== "beauftragt" && + !!offer.gueltig_bis && + Date.parse(offer.gueltig_bis) < Date.now(); + const unsentOfferCount = offers.filter( + (offer) => + !offer.versendet && offer.status !== "beauftragt" && !offerExpired(offer), + ).length; + const openOfferCount = offers.filter( + (offer) => + !!offer.versendet && + offer.status !== "beauftragt" && + !offerExpired(offer), + ).length; + const openInvoiceCreateCount = openInvoiceCreateTasks.length; + const openInvoiceReviewCount = invoiceReviews.filter( + (invoice) => invoice.status !== "erledigt", + ).length; + const openInvoiceSendCount = invoiceSendTasks.filter( + (task) => !task.versendet_am, + ).length; + const dashboardGreeting = + dashboardNow.getHours() < 12 + ? "Guten Morgen" + : dashboardNow.getHours() < 18 + ? "Guten Tag" + : "Guten Abend"; + const dashboardDate = new Intl.DateTimeFormat("de-DE", { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }).format(dashboardNow); + const recentServiceTickets = tickets.filter((ticket) => { + const date = new Date(ticket.updatedAt); + if (Number.isNaN(date.getTime())) return false; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + return date >= yesterday && date < tomorrow; + }); + const svenjaStartItems = [ + { + view: "tickets", + icon: "▣", + label: "Offene Tickets", + detail: "Vorgänge warten auf Bearbeitung", + count: tickets.length, + }, + { + view: "offers-all", + icon: "▤", + label: "Erstellte Angebote", + detail: "Angebote warten noch auf den Versand", + count: unsentOfferCount, + }, + { + view: "offers-open", + icon: "▤", + label: "Offene Angebote", + detail: "Versendet – Rückmeldung steht noch aus", + count: openOfferCount, + }, + { + view: "internal-tasks", + icon: "☑", + label: "Interne Aufgaben", + detail: "Für dich oder gemeinsam", + count: openTaskCount("svenja"), + }, + { + view: "invoices-create", + icon: "✎", + label: "Rechnungen anfertigen", + detail: "Rechnungsaufgaben warten auf dich", + count: openInvoiceCreateCount, + }, + { + view: "invoice-review-svenja", + icon: "◈", + label: "Rechnungen in Prüfung", + detail: "Rechnungen in Saschas Prüfliste", + count: openInvoiceReviewCount, + }, + { + view: "invoices-send", + icon: "✉", + label: "Rechnungen zum Versenden", + detail: "Geprüfte Rechnungen warten auf den Versand", + count: openInvoiceSendCount, + }, + { + view: "online-purchases", + icon: "⌘", + label: "Online Käufe", + detail: "Belege warten auf die Ablage", + count: openOnlinePurchaseCount, + }, + ].filter((item) => item.count > 0); + const visibleCustomers = customers + .filter((customer) => + [ + customer.kundennummer_bestand, + customer.firma, + customer.anrede, + customer.name, + customer.ort, + customer.telefon, + customer.mobil, + customer.email, + ] + .join(" ") + .toLocaleLowerCase("de-DE") + .includes(customerSearch.toLocaleLowerCase("de-DE")), + ) + .sort((left, right) => { + const collator = new Intl.Collator("de-DE", { + numeric: true, + sensitivity: "base", + }); + const byName = collator.compare( + left.firma || left.name || "", + right.firma || right.name || "", + ); + const byNumber = collator.compare( + left.kundennummer_bestand || "", + right.kundennummer_bestand || "", + ); + if (customerSort === "name-desc") return -byName; + if (customerSort === "number-asc") return byNumber; + if (customerSort === "number-desc") return -byNumber; + return byName; + }); + const refreshOffers = async () => { + setOffersLoading(true); + setOffersError(""); + try { + const response = await fetch("/api/offers", { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Die Angebote konnten nicht geladen werden.", + ); + setOffers( + (Array.isArray(data.offers) ? data.offers : []).sort( + (a: Offer, b: Offer) => + `${b.erstellt_am ?? ""}`.localeCompare(`${a.erstellt_am ?? ""}`), + ), + ); + } catch (error) { + setOffersError( + error instanceof Error + ? error.message + : "Die Angebote konnten nicht geladen werden.", + ); + } finally { + setOffersLoading(false); + } + }; + const updateOffer = async ( + offer: Offer, + action: "versendet" | "beauftragt" | "zuruecksetzen", + ) => { + setOfferBusy(`${action}-${offer.id}`); + setOffersError(""); + try { + const response = await fetch("/api/offers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + action === "versendet" + ? { + id: offer.id, + action, + versandart: offerShipping[offer.id] ?? "E-Mail", + } + : action === "beauftragt" + ? { + id: offer.id, + action, + angebot_nummer: offer.angebot_nummer ?? "", + kunde: offer.kunde ?? "", + objekt_zusatz: offer.objekt_zusatz ?? "", + } + : { id: offer.id, action }, + ), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Das Angebot konnte nicht aktualisiert werden.", + ); + await refreshOffers(); + } catch (error) { + setOffersError( + error instanceof Error + ? error.message + : "Das Angebot konnte nicht aktualisiert werden.", + ); + } finally { + setOfferBusy(null); + setOfferConfirming(null); + } + }; + const deleteOffer = async (offer: Offer) => { + if ( + !window.confirm( + `Angebot ${offer.angebot_nummer || offer.dateiname} wirklich löschen?`, + ) + ) + return; + setOfferBusy(`delete-${offer.id}`); + setOffersError(""); + try { + const response = await fetch("/api/offers", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: offer.id }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Das Angebot konnte nicht gelöscht werden.", + ); + setOffers((items) => items.filter((item) => item.id !== offer.id)); + } catch (error) { + setOffersError( + error instanceof Error + ? error.message + : "Das Angebot konnte nicht gelöscht werden.", + ); + } finally { + setOfferBusy(null); + } + }; + const refreshCustomers = async () => { + setCustomersLoading(true); + setCustomersError(""); + try { + const response = await fetch("/api/customers", { cache: "no-store" }); + const data = await response.json(); + if (!response.ok) + throw new Error( + data.error ?? "Der Kundenstamm konnte nicht geladen werden.", + ); + setCustomers(Array.isArray(data.customers) ? data.customers : []); + } catch (error) { + setCustomersError( + error instanceof Error + ? error.message + : "Der Kundenstamm konnte nicht geladen werden.", + ); + } finally { + setCustomersLoading(false); + } + }; + const syncCustomerInvoices = async () => { + const response = await fetch("/api/customer-invoices/sync", { + method: "POST", + cache: "no-store", + }).catch(() => null); + const data = response ? await response.json().catch(() => ({})) : {}; + if (!response?.ok) + throw new Error( + data.error ?? "Der Rechnungsabgleich ist zurzeit nicht verfügbar.", + ); + }; + const customerInvoiceAliases = (customer: Customer) => + [ + ...new Set( + [ + customer.firma, + customer.name, + [customer.anrede, customer.name].filter(Boolean).join(" "), + ] + .map((value) => String(value ?? "").trim()) + .filter(Boolean), + ), + ].join("|"); + const normalizeCustomerName = (value?: string) => + String(value ?? "") + .toLocaleLowerCase("de-DE") + .replace(/^(herrn?|frau)\s+/, "") + .replace(/[ä]/g, "ae") + .replace(/[ö]/g, "oe") + .replace(/[ü]/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const knownCustomerInvoiceNames = new Set( + customers.flatMap((customer) => + [ + customer.firma, + customer.name, + [customer.anrede, customer.name].filter(Boolean).join(" "), + ] + .map(normalizeCustomerName) + .filter(Boolean), + ), + ); + const unassignedInvoices = invoiceOverview.filter( + (invoice) => + !knownCustomerInvoiceNames.has(normalizeCustomerName(invoice.kunde)), + ); + const refreshInvoiceOverview = async () => { + setInvoiceOverviewLoading(true); + setInvoiceOverviewError(""); + try { + const response = await fetch("/api/customer-invoices?mode=all", { + cache: "no-store", + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Rechnungen konnten nicht geladen werden.", + ); + setInvoiceOverview( + (Array.isArray(data.invoices) ? data.invoices : []).sort( + (left: CustomerInvoice, right: CustomerInvoice) => + String(right.rechnungsdatum ?? "").localeCompare( + String(left.rechnungsdatum ?? ""), + ), + ), + ); + } catch (error) { + setInvoiceOverviewError( + error instanceof Error + ? error.message + : "Rechnungen konnten nicht geladen werden.", + ); + } finally { + setInvoiceOverviewLoading(false); + } + }; + const assignInvoiceToCustomer = async ( + invoice: CustomerInvoice, + customer: Customer, + ) => { + const id = String(invoice.id ?? ""); + const kunde = customer.firma || customer.name || ""; + if (!id || !kunde) return; + setInvoiceAssignmentBusy(id); + setInvoiceOverviewError(""); + try { + const response = await fetch("/api/customer-invoices", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id, + kunde, + kundennummer: customer.kundennummer_bestand ?? "", + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(data.error ?? "Rechnung konnte nicht zugeordnet werden."); + setInvoiceAssignmentSearch((current) => ({ ...current, [id]: "" })); + await refreshInvoiceOverview(); + } catch (error) { + setInvoiceOverviewError( + error instanceof Error + ? error.message + : "Rechnung konnte nicht zugeordnet werden.", + ); + } finally { + setInvoiceAssignmentBusy(null); + } + }; + const openCustomerInvoices = async (customer: Customer) => { + const kunde = customer.firma || customer.name || ""; + setInvoiceCustomer(customer); + setCustomerInvoicesLoading(true); + setInvoiceProperties([]); + setPropertyInvoices({}); + setPropertyInvoiceError({}); + try { + await syncCustomerInvoices(); + const response = await fetch( + `/api/customer-invoices?kunde=${encodeURIComponent(kunde)}&aliases=${encodeURIComponent(customerInvoiceAliases(customer))}&mode=summary`, + { cache: "no-store" }, + ); + const data = await response.json(); + if (!response.ok) + throw new Error( + data.error ?? "Rechnungen konnten nicht geladen werden.", + ); + setInvoiceProperties( + Array.isArray(data.properties) ? data.properties : [], + ); + } catch (error) { + setCustomersError( + error instanceof Error + ? error.message + : "Rechnungen konnten nicht geladen werden.", + ); + } finally { + setCustomerInvoicesLoading(false); + } + }; + const loadPropertyInvoices = async (liegenschaft: string) => { + if ( + !invoiceCustomer || + propertyInvoices[liegenschaft] || + propertyInvoiceLoading === liegenschaft + ) + return; + const kunde = invoiceCustomer.firma || invoiceCustomer.name || ""; + setPropertyInvoiceLoading(liegenschaft); + setPropertyInvoiceError((errors) => ({ ...errors, [liegenschaft]: "" })); + try { + const response = await fetch( + `/api/customer-invoices?kunde=${encodeURIComponent(kunde)}&aliases=${encodeURIComponent(customerInvoiceAliases(invoiceCustomer))}&liegenschaft=${encodeURIComponent(liegenschaft)}`, + { cache: "no-store" }, + ); + const data = await response.json(); + if (!response.ok) + throw new Error( + data.error ?? "Rechnungen konnten nicht geladen werden.", + ); + setPropertyInvoices((items) => ({ + ...items, + [liegenschaft]: Array.isArray(data.invoices) ? data.invoices : [], + })); + } catch (error) { + setPropertyInvoiceError((errors) => ({ + ...errors, + [liegenschaft]: + error instanceof Error + ? error.message + : "Rechnungen konnten nicht geladen werden.", + })); + } finally { + setPropertyInvoiceLoading((current) => + current === liegenschaft ? null : current, + ); + } + }; + const saveCustomer = async () => { + if (!editingCustomer) return; + if (!(editingCustomer.firma ?? "").trim() && !(editingCustomer.name ?? "").trim()) { + setCustomersError("Bitte mindestens Firma oder Kundenname eintragen."); + return; + } + setCustomerSaving(true); + setCustomersError(""); + try { + const response = await fetch("/api/customers", { + method: creatingCustomer ? "POST" : "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(editingCustomer), + }); + const data = await response.json(); + if (!response.ok) + throw new Error( + data.error ?? "Die Kundendaten konnten nicht gespeichert werden.", + ); + if (creatingCustomer) await refreshCustomers(); + else setCustomers((items) => + items.map((item) => + item.id === editingCustomer.id + ? { + ...item, + ...editingCustomer, + aktualisiert_am: + data.customer?.aktualisiert_am ?? new Date().toISOString(), + } + : item, + ), + ); + setEditingCustomer(null); + setCreatingCustomer(false); + } catch (error) { + setCustomersError( + error instanceof Error + ? error.message + : "Die Kundendaten konnten nicht gespeichert werden.", + ); + } finally { + setCustomerSaving(false); + } + }; + const deleteCustomer = async () => { + if (!editingCustomer || !isSascha) return; + setCustomerSaving(true); + setCustomersError(""); + try { + const response = await fetch("/api/customers", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: editingCustomer.id }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? + "Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.", + ); + setCustomers((items) => + items.filter((item) => item.id !== editingCustomer.id), + ); + setEditingCustomer(null); + setCreatingCustomer(false); + } catch (error) { + setCustomersError( + error instanceof Error + ? error.message + : "Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.", + ); + } finally { + setCustomerSaving(false); + setCustomerDeleteConfirming(false); + } + }; + const refreshOnlinePurchases = async () => { + setOnlinePurchasesLoading(true); + setOnlinePurchasesError(""); + try { + const response = await fetch("/api/online-purchases", { + cache: "no-store", + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Die Online-Käufe konnten nicht geladen werden.", + ); + const purchases = Array.isArray(data.purchases) ? data.purchases : []; + purchases.sort((left: OnlinePurchase, right: OnlinePurchase) => { + const leftDone = left.status?.toLocaleLowerCase("de-DE") === "erledigt"; + const rightDone = + right.status?.toLocaleLowerCase("de-DE") === "erledigt"; + if (leftDone !== rightDone) return leftDone ? 1 : -1; + return `${right.kaufdatum ?? ""}${right.angelegt_am ?? ""}`.localeCompare( + `${left.kaufdatum ?? ""}${left.angelegt_am ?? ""}`, + "de", + ); + }); + setOnlinePurchases(purchases); + } catch (error) { + setOnlinePurchasesError( + error instanceof Error + ? error.message + : "Die Online-Käufe konnten nicht geladen werden.", + ); + } finally { + setOnlinePurchasesLoading(false); + } + }; + const saveOnlinePurchase = async ( + event: FormEvent, + ) => { + event.preventDefault(); + const artikel = onlinePurchaseForm.artikel.trim(); + const kaufort = onlinePurchaseForm.kaufort.trim(); + const kaufdatum = onlinePurchaseForm.kaufdatum; + if (!artikel || !kaufort || !kaufdatum) { + setOnlinePurchasesError( + "Bitte Artikel, Marktplatz und Kaufdatum ausfüllen.", + ); + return; + } + setOnlinePurchaseSaving(true); + setOnlinePurchasesError(""); + try { + const response = await fetch("/api/online-purchases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ artikel, kaufort, kaufdatum }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Der Online-Kauf konnte nicht gespeichert werden.", + ); + setOnlinePurchaseForm({ + artikel: "", + kaufort: "", + kaufdatum: new Date().toISOString().slice(0, 10), + }); + await refreshOnlinePurchases(); + } catch (error) { + setOnlinePurchasesError( + error instanceof Error + ? error.message + : "Der Online-Kauf konnte nicht gespeichert werden.", + ); + } finally { + setOnlinePurchaseSaving(false); + } + }; + const completeOnlinePurchase = async (purchase: OnlinePurchase) => { + setOnlinePurchaseCompleting(purchase.id); + setOnlinePurchasesError(""); + try { + const response = await fetch("/api/online-purchases", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: String(purchase.id) }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Der Kauf konnte nicht abgeschlossen werden.", + ); + setOnlinePurchases((items) => + items.map((item) => + item.id === purchase.id + ? { + ...item, + status: "erledigt", + erledigt_von: "Svenja", + erledigt_am: data.erledigt_am ?? new Date().toISOString(), + } + : item, + ), + ); + } catch (error) { + setOnlinePurchasesError( + error instanceof Error + ? error.message + : "Der Kauf konnte nicht abgeschlossen werden.", + ); + } finally { + setOnlinePurchaseCompleting(null); + } + }; + const refreshInternalTasks = async () => { + const response = await fetch("/api/internal-tasks", { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + setInternalTaskError( + data.error ?? "Interne Aufgaben konnten nicht geladen werden.", + ); + return; + } + setInternalTasks( + (data.tasks ?? []).sort( + (a: InternalTask, b: InternalTask) => + (a.status === "erledigt" ? 1 : 0) - + (b.status === "erledigt" ? 1 : 0) || + `${b.erstellt_am ?? ""}`.localeCompare(`${a.erstellt_am ?? ""}`), + ), + ); + }; + const saveInternalTask = async ( + event: FormEvent, + empfaenger: "sascha" | "svenja", + ) => { + event.preventDefault(); + const draft = internalTaskDrafts[empfaenger] ?? { + aufgabe: "", + kategorie: "angebote", + kunden_id: "", + }; + const aufgabe = draft.aufgabe.trim(); + if (!aufgabe) return; + setInternalTaskBusy(`new-${empfaenger}`); + setInternalTaskError(""); + const response = await fetch("/api/internal-tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + aufgabe, + empfaenger, + kategorie: draft.kategorie, + kunden_id: draft.kunden_id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + setInternalTaskError( + data.error ?? "Aufgabe konnte nicht gespeichert werden.", + ); + else { + setInternalTaskDrafts((value) => ({ + ...value, + [empfaenger]: { aufgabe: "", kategorie: "angebote", kunden_id: "" }, + })); + setInternalTaskCustomerSearch((value) => ({ + ...value, + [empfaenger]: "", + })); + await refreshInternalTasks(); + } + setInternalTaskBusy(null); + }; + const changeInternalTask = async ( + task: InternalTask, + method: "PUT" | "DELETE", + ) => { + setInternalTaskBusy(task.id); + setInternalTaskError(""); + const response = await fetch("/api/internal-tasks", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: task.id }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + setInternalTaskError( + data.error ?? "Aufgabe konnte nicht geändert werden.", + ); + else await refreshInternalTasks(); + setInternalTaskBusy(null); + }; + const updateInternalTask = async ( + event: FormEvent, + ) => { + event.preventDefault(); + if (!editingInternalTask) return; + const form = new FormData(event.currentTarget); + const aufgabe = String(form.get("aufgabe") ?? "").trim(); + const empfaenger = String(form.get("empfaenger") ?? ""); + const kategorie = String(form.get("kategorie") ?? ""); + const kunden_id = String(form.get("kunden_id") ?? ""); + setInternalTaskBusy(editingInternalTask.id); + setInternalTaskError(""); + const response = await fetch("/api/internal-tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: editingInternalTask.id, + aufgabe, + empfaenger, + kategorie, + kunden_id, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + setInternalTaskError( + data.error ?? "Aufgabe konnte nicht bearbeitet werden.", + ); + else { + setEditingInternalTask(null); + await refreshInternalTasks(); + } + setInternalTaskBusy(null); + }; + const deleteOnlinePurchase = async (purchase: OnlinePurchase) => { + setOnlinePurchaseDeleting(purchase.id); + setOnlinePurchasesError(""); + try { + const response = await fetch("/api/online-purchases", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: String(purchase.id) }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(data.error ?? "Der Kauf konnte nicht gelöscht werden."); + setOnlinePurchases((items) => + items.filter((item) => item.id !== purchase.id), + ); + } catch (error) { + setOnlinePurchasesError( + error instanceof Error + ? error.message + : "Der Kauf konnte nicht gelöscht werden.", + ); + } finally { + setOnlinePurchaseDeleting(null); + } + }; + const refreshInvoiceReviews = async () => { + setInvoiceReviewsLoading(true); + setInvoiceReviewsError(""); + try { + const response = await fetch("/api/invoices-review", { + cache: "no-store", + }); + const data = await response.json(); + if (!response.ok) + throw new Error( + data.error ?? "Die Prüfliste konnte nicht geladen werden.", + ); + setInvoiceReviews(Array.isArray(data.invoices) ? data.invoices : []); + setInvoiceReviewsRefreshedAt( + data.generatedAt ?? new Date().toISOString(), + ); + } catch (error) { + setInvoiceReviewsError( + error instanceof Error + ? error.message + : "Die Prüfliste konnte nicht geladen werden.", + ); + } finally { + setInvoiceReviewsLoading(false); + } + }; + const refreshInvoiceCreateTasks = async () => { + setInvoiceCreateLoading(true); + setInvoiceCreateError(""); + try { + const response = await fetch("/api/invoices-create", { + cache: "no-store", + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Die Rechnungsaufgaben konnten nicht geladen werden.", + ); + setInvoiceCreateTasks(Array.isArray(data.invoices) ? data.invoices : []); + setInvoiceCreateRefreshedAt(data.generatedAt ?? new Date().toISOString()); + } catch (error) { + setInvoiceCreateError( + error instanceof Error + ? error.message + : "Die Rechnungsaufgaben konnten nicht geladen werden.", + ); + } finally { + setInvoiceCreateLoading(false); + } + }; + const refreshInvoiceSendTasks = async () => { + setInvoiceSendLoading(true); + setInvoiceSendError(""); + try { + const response = await fetch("/api/invoices-send", { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Die Versandaufgaben konnten nicht geladen werden.", + ); + setInvoiceSendTasks(Array.isArray(data.invoices) ? data.invoices : []); + setInvoiceSendRefreshedAt(data.generatedAt ?? new Date().toISOString()); + } catch (error) { + setInvoiceSendError( + error instanceof Error + ? error.message + : "Die Versandaufgaben konnten nicht geladen werden.", + ); + } finally { + setInvoiceSendLoading(false); + } + }; + const completeInvoiceCreateTask = async (task: InvoiceReview) => { + setInvoiceCreateCompleting(task.id); + setInvoiceCreateError(""); + try { + const response = await fetch("/api/invoices-create", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: String(task.id) }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? + "Die Rechnungsaufgabe konnte nicht abgeschlossen werden.", + ); + setInvoiceCreateTasks((tasks) => + tasks.map((item) => + item.id === task.id + ? { + ...item, + status: "erledigt", + completedAt: data.erledigt_am ?? new Date().toISOString(), + } + : item, + ), + ); + } catch (error) { + setInvoiceCreateError( + error instanceof Error + ? error.message + : "Die Rechnungsaufgabe konnte nicht abgeschlossen werden.", + ); + } finally { + setInvoiceCreateCompleting(null); + } + }; + const completeInvoiceReview = async (invoice: InvoiceReview) => { + setInvoiceReviewCompleting(invoice.id); + setInvoiceReviewsError(""); + try { + const response = await fetch("/api/invoices-review", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: String(invoice.id) }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data?.ok === false) + throw new Error( + data?.error ?? + "Die Rechnungsprüfung konnte nicht abgeschlossen werden.", + ); + await refreshInvoiceReviews(); + } catch (error) { + setInvoiceReviewsError( + error instanceof Error + ? error.message + : "Die Rechnungsprüfung konnte nicht abgeschlossen werden.", + ); + } finally { + setInvoiceReviewCompleting(null); + } + }; + useEffect(() => { + document.documentElement.dataset.theme = "dark"; + }, []); + useEffect(() => { + const timer = window.setInterval(() => setDashboardNow(new Date()), 30_000); + return () => window.clearInterval(timer); + }, []); + // Die Navigations-Badges müssen sofort nach der Anmeldung sichtbar sein. + useEffect(() => { + if (user) void refreshInvoiceReviews(); + }, [user]); + useEffect(() => { + if (isSvenja) void refreshInvoiceCreateTasks(); + }, [user]); + useEffect(() => { + if (isSvenja) void refreshInvoiceSendTasks(); + }, [user]); + useEffect(() => { + if (user) void refreshCustomers(); + }, [user]); + useEffect(() => { + if (user) void syncCustomerInvoices().catch(() => undefined); + }, [user]); + useEffect(() => { + if (user && view === "customer-invoices") void refreshInvoiceOverview(); + }, [user, view]); + useEffect(() => { + if (user) void refreshOffers(); + }, [user]); + useEffect(() => { + if ( + user && + ((isSascha && view === "online-purchases-new") || + (isSvenja && view === "online-purchases")) + ) + void refreshOnlinePurchases(); + }, [user, view]); + useEffect(() => { + if (user) void refreshOnlinePurchases(); + }, [user]); + useEffect(() => { + if (user) + void refreshInternalTasks().catch(() => + setInternalTaskError("Interne Aufgaben konnten nicht geladen werden."), + ); + }, [user]); + useEffect(() => { + if (!user) return; + let active = true; + const load = async () => { + setHoursLoading(true); + setHoursError(""); + const response = await fetch("/api/hours", { cache: "no-store" }).catch( + () => null, + ); + const data = response ? await response.json().catch(() => ({})) : {}; + if (!active) return; + if (!response?.ok) { + setHoursError( + data.error ?? "Die Stundennachweise konnten nicht geladen werden.", + ); + setHoursLoading(false); + return; + } + setHoursOverview(data); + setHoursLoading(false); + }; + void load(); + return () => { + active = false; + }; + }, [user]); + // Auth-Sonde, Sitzungsverlängerung und Leerlaufabmeldung liegen jetzt im + // AuthProvider. Diese Ansicht bekommt den angemeldeten Benutzer fertig geliefert. + const refreshTickets = async () => { + setTicketsLoading(true); + setTicketsError(""); + const response = await fetch("/api/tickets", { cache: "no-store" }).catch( + () => null, + ); + const data = response ? await response.json().catch(() => ({})) : {}; + if (!response?.ok) { + setTicketsError( + data.error ?? "Die offenen Tickets konnten nicht geladen werden.", + ); + setTicketsLoading(false); + return; + } + setTickets(Array.isArray(data.tickets) ? data.tickets : []); + setTicketsRefreshedAt(data.refreshedAt ?? new Date().toISOString()); + setTicketsLoading(false); + }; + useEffect(() => { + if (user) void refreshTickets(); + }, [user]); + const needsCustomerConsultation = (ticket: OpenTicket) => + ticket.requiresCustomerConsultation === true; + const saveCustomerConsultation = async (ticket: OpenTicket) => { + const result = ( + consultationDrafts[ticket.id] ?? + ticket.consultationResult ?? + "" + ).trim(); + if (!result) { + setConsultationError((errors) => ({ + ...errors, + [ticket.id]: "Bitte trage das Ergebnis der Rücksprache ein.", + })); + return; + } + setConsultationSaving(ticket.id); + setConsultationError((errors) => ({ ...errors, [ticket.id]: "" })); + try { + const response = await fetch("/api/tickets/consultation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ticketnummer: ticket.id, ergebnis: result }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + data.error ?? "Das Ergebnis konnte nicht gespeichert werden.", + ); + const updatedAt = data.updatedAt ?? new Date().toISOString(); + setTickets((items) => + items.map((item) => + item.id === ticket.id + ? { + ...item, + consultationResult: result, + consultationUpdatedAt: updatedAt, + consultationBy: "Svenja", + } + : item, + ), + ); + setConsultationDrafts((drafts) => ({ ...drafts, [ticket.id]: result })); + } catch (error) { + setConsultationError((errors) => ({ + ...errors, + [ticket.id]: + error instanceof Error + ? error.message + : "Das Ergebnis konnte nicht gespeichert werden.", + })); + } finally { + setConsultationSaving(null); + } + }; + useEffect(() => { + if (!user || view !== "tickets") return; + let active = true; + const load = async () => { + const response = await fetch("/api/tickets", { cache: "no-store" }).catch( + () => null, + ); + const data = response ? await response.json().catch(() => ({})) : {}; + if (!active) return; + if (!response?.ok) { + setTicketsError( + data.error ?? "Die offenen Tickets konnten nicht geladen werden.", + ); + return; + } + setTickets(Array.isArray(data.tickets) ? data.tickets : []); + setTicketsRefreshedAt(data.refreshedAt ?? new Date().toISOString()); + setTicketsError(""); + }; + void load(); + const interval = window.setInterval(() => void load(), 20000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [user, view]); + useEffect(() => { + if (!user || view !== "time-records") return; + let active = true; + const load = async () => { + setHoursLoading(true); + setHoursError(""); + const response = await fetch("/api/hours", { cache: "no-store" }).catch( + () => null, + ); + const data = response ? await response.json().catch(() => ({})) : {}; + if (!active) return; + if (!response?.ok) { + setHoursError( + data.error ?? "Die Stundennachweise konnten nicht geladen werden.", + ); + setHoursLoading(false); + return; + } + setHoursOverview(data); + setHoursLoading(false); + }; + void load(); + return () => { + active = false; + }; + }, [user, view]); + // Anmeldemaske und Ladezustand rendert die AuthGate-Hülle in App.tsx. + // Ab hier ist garantiert jemand angemeldet. + const logout = signOut; + + // App.tsx rendert diese Ansicht nur angemeldet. Die Abfrage engt für + // TypeScript den Typ von `user` auf string ein und kostet sonst nichts. + if (!user) return null; + + return ( +
+
+
+ Elektro Krüger – Wir setzen Sie unter Strom. +
+
+ EK-DOS + Die digitale Schaltzentrale +
+
+ {user.slice(0, 1)} + + {user} + angemeldet + + +
+
+
+ + {view === "users" ? ( + + ) : view === "customers" ? ( +
+
+
+

EK-DOS · STAMMDATEN

+

Kundenstamm

+

Kundendaten, interne Notizen und direkte Neuanlage von Kunden.

+
+
+ + +
+
+
+ setCustomerSearch(event.target.value)} + placeholder="Kunde, Firma, Ort oder Telefonnummer suchen …" + /> + + + {visibleCustomers.length} von {customers.length} Kunden + +
+ {customersError && ( +
{customersError}
+ )} + {!customersError && customersLoading && customers.length === 0 && ( +
+ Kundenstamm wird geladen … +
+ )} + {!customersError && customers.length > 0 && ( +
+ + + + + + + + + + + + {visibleCustomers.map((customer) => { + const contactName = [customer.anrede, customer.name] + .filter(Boolean) + .join(" "); + return ( + + + + + + + + + ); + })} + +
KundennummerKundeAnschriftKontaktInterne Notiz +
+ {customer.kundennummer_bestand || "–"} + + + {customer.firma || + contactName || + "Unbenannter Kunde"} + + {customer.firma && contactName && ( + {contactName} + )} + + {[ + customer.strasse, + [customer.plz, customer.ort] + .filter(Boolean) + .join(" "), + ] + .filter(Boolean) + .join(" · ") || "–"} + + {[ + customer.telefon && `Tel. ${customer.telefon}`, + customer.mobil && `Mobil ${customer.mobil}`, + customer.email, + ] + .filter(Boolean) + .join(" · ") || "–"} + + {customer.notiz || "–"} + +
+ + +
+
+
+ )} + {invoiceCustomer && ( +
+
+
+

RECHNUNGEN

+

+ {invoiceCustomer.firma || invoiceCustomer.name || "Kunde"} +

+

+ Rechnungen nach Liegenschaft +

+
+ {customerInvoicesLoading ? ( +

Rechnungen werden geladen …

+ ) : invoiceProperties.length === 0 ? ( +

+ Für diesen Kunden liegen noch keine zugeordneten + Rechnungen vor. +

+ ) : ( +
+ {invoiceProperties.map(({ liegenschaft, count }) => { + const invoices = propertyInvoices[liegenschaft]; + const loading = propertyInvoiceLoading === liegenschaft; + return ( +
{ + if (event.currentTarget.open) + void loadPropertyInvoices(liegenschaft); + }} + > + + + + {liegenschaft} + + {count}{" "} + {count === 1 ? "Rechnung" : "Rechnungen"} + + + + +
+ {loading ? ( +

Rechnungen werden geladen …

+ ) : propertyInvoiceError[liegenschaft] ? ( +

{propertyInvoiceError[liegenschaft]}

+ ) : ( + invoices?.map((invoice) => ( +
+ + + Rechnung{" "} + {invoice.rechnungsnummer || + invoice.dateiname} + + + {formatDate(invoice.rechnungsdatum)} + + + + PDF öffnen + +
+ )) + )} +
+
+ ); + })} +
+ )} +
+ +
+
+
+ )} + {editingCustomer && ( +
+
{ + event.preventDefault(); + void saveCustomer(); + }} + > +
+

+ {creatingCustomer ? "KUNDEN ANLEGEN" : "KUNDENDATEN ÄNDERN"} +

+

+ {creatingCustomer + ? "Neuen Kunden anlegen" + : editingCustomer.firma || + [editingCustomer.anrede, editingCustomer.name] + .filter(Boolean) + .join(" ") || + "Kunde"} +

+
+ {!creatingCustomer && + Letzte digitale Akte öffnen + } +
+ + + + + + + + + +