Der Dateibaum im README und in 05/08, die Kommentare hinter # in den Beispielbefehlen und die Beschriftungen in Mermaid-Sequenzdiagrammen sind Lesetext, stehen aber in Codeblöcken - die erste Runde hat sie deshalb übersprungen. Die Befehle selbst und alle Bezeichner bleiben unverändert: ersetzt wurde nur hinter "# ", hinter den zwei Leerzeichen des Dateibaums und hinter ": " in Mermaid-Nachrichten. ER-Labels wie bewertet_fuer sind Bezeichner und bleiben, "systemctl daemon-reload" ebenfalls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
669 lines
32 KiB
Markdown
669 lines
32 KiB
Markdown
# perinco CRM
|
|
|
|
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
|
|
Enthält den portierten *Projekt Scanner* und die lesende Anbindung des
|
|
Vertriebspostfachs `crm@perinco.de` über Microsoft Graph.
|
|
|
|
> **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
|
|
> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb,
|
|
> [Postfachanbindung](docs/11-postfach.md).
|
|
> Zum Einstieg: [Anleitung für Benutzer](docs/07-anleitung-benutzer.md) ·
|
|
> [Anleitung für Entwickler](docs/08-anleitung-entwickler.md) ·
|
|
> [Quellcode-Referenz](docs/09-quellcode.md)
|
|
|
|
## Corporate Identity
|
|
|
|
Direkt aus dem perinco Logo gesampelt:
|
|
|
|
| Rolle | Wert |
|
|
| --- | --- |
|
|
| Primärfarbe (Grün) | `#6BA04A`, Akzent `#68A33F` |
|
|
| Sekundärfarbe (Grau) | `#6E6E6E` |
|
|
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
|
|
|
|
Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`)
|
|
sowie die Flächen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
|
|
|
|
## Dark Mode
|
|
|
|
Umschalter rechts oben im Header. Die Wahl liegt in `localStorage`
|
|
(`perinco_crm_theme`); ohne Wahl folgt die App der Systemeinstellung, ein
|
|
Doppelklick auf den Umschalter stellt das wieder her.
|
|
|
|
Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse
|
|
`dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Variablen
|
|
`canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben. Die
|
|
Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch.
|
|
|
|
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewählt:
|
|
|
|
| Was | Warum |
|
|
| --- | --- |
|
|
| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben |
|
|
| `text-white` | steht immer auf farbigen Flächen (Buttons, Termin-Chips) |
|
|
| `brand-500` | das CI-Grün der Buttons; nur die Tints darum kippen |
|
|
|
|
Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt
|
|
deshalb `var(--color-*)` direkt. Das Logo ist für helle Hintergründe gemacht -
|
|
die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per
|
|
`hue-rotate` das Grün zurück.
|
|
|
|
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
|
|
blitzt beim Laden die helle Variante auf.
|
|
|
|
## Architektur
|
|
|
|
```
|
|
Browser ──► Apache (:443/:80, pcrm.perinco.de)
|
|
├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource)
|
|
└── /api → 127.0.0.1:5006 (Express)
|
|
├──► MariaDB perinco_CRM (CRM + Postfach)
|
|
├──► MariaDB perinco_scanner (Scanner)
|
|
└──► Microsoft Graph (crm@perinco.de, nur lesend)
|
|
```
|
|
|
|
Der Scanner hängt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` -
|
|
beide brauchen damit **keine** eigene Apache-Regel.
|
|
|
|
Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend.
|
|
Das Backend bindet ausschließlich auf `127.0.0.1` und ist von außen nur über
|
|
den Apache-Proxy erreichbar.
|
|
|
|
### Datenmodell
|
|
|
|
| Tabelle | Inhalt |
|
|
| --- | --- |
|
|
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
|
|
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zuständig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
|
|
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
|
|
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
|
|
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
|
|
| `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten |
|
|
| `crm_event_participants` | Teilnehmer je Termin (n:m), Primärschlüssel verhindert Doppeleinträge |
|
|
|
|
### Rollen
|
|
|
|
* `admin` - sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltung
|
|
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab
|
|
|
|
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
|
|
degradiert, deaktiviert oder gelöscht wird.
|
|
|
|
Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste
|
|
brauchen alle Rollen für Netzwerkdiagramm, Zuständigkeiten, @-Mentions,
|
|
Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie.
|
|
Termine darf jeder anlegen; ändern und löschen nur der Ersteller oder ein
|
|
Administrator.
|
|
|
|
## Struktur
|
|
|
|
```
|
|
src/
|
|
App.tsx Layout, Datenladen, View-Routing
|
|
types.ts Gemeinsame Typen und Konstanten
|
|
lib/api.ts Fetch-Wrapper + typisierte Endpunkte
|
|
lib/auth.tsx Auth-Context (JWT im localStorage)
|
|
lib/theme.tsx Theme-Context (hell/dunkel/System)
|
|
lib/navigation.tsx Ansichtswechsel für tief liegende Komponenten
|
|
lib/whisper.tsx Modelleinstellungen, Worker-Lebenszyklus, Zustand
|
|
lib/whisper.worker.ts Whisper im Web Worker (Laden und Erkennen)
|
|
lib/audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
|
lib/dictation.tsx Aufnahme-Hook (Mikrofon, Dauer, Abbruch)
|
|
lib/imageCompress.ts Bilder im Browser auf 256 px verkleinern
|
|
components/
|
|
Sidebar.tsx Menü links (Opportunities, Kalender, Kontakte,
|
|
Scanner mit aufklappbaren Unterpunkten);
|
|
unten Whisper (alle) und Einstellungen (admin only)
|
|
Header.tsx Titel, Menü-Toggle, Umschalter für den Dark Mode
|
|
Toast.tsx, LoginScreen.tsx
|
|
ui/Modal.tsx Modal + Bestaetigungsdialog
|
|
ui/UserAvatar.tsx Benutzerfoto, sonst Initiale auf der Benutzerfarbe
|
|
ui/AvatarPicker.tsx Foto-Upload mit Vorschau (Kontakte und Benutzer)
|
|
ui/DictationField.tsx Mikrofon-Button, Hinweisbox, Textfeld mit Diktat
|
|
whisper/
|
|
WhisperSettings.tsx Reiter Whisper: Geraetecheck, Modellwahl, Probe
|
|
opportunities/
|
|
Opportunities.tsx Suche, Phasenfilter, Sortierung, Ansichtswechsel
|
|
OpportunityGrid.tsx Kachelansicht
|
|
OpportunityList.tsx Sortierbare Tabelle
|
|
OpportunityPipeline.tsx Kanban nach Phase mit Drag & Drop
|
|
OpportunityModal.tsx Formular inkl. Ansprechpartnern und Diktat
|
|
opportunityUi.tsx Phasenfarben, Aufklapptext, Chancenbalken
|
|
calendar/
|
|
Calendar.tsx Monat/Woche/Tag + Liste der nächsten Termine
|
|
EventModal.tsx Terminformular inkl. Teilnehmerauswahl
|
|
EventDetailModal.tsx Leseansicht mit klickbaren @-Mentions
|
|
UserDetailModal.tsx Detailfenster für markierte Benutzer
|
|
mentions.tsx Mention-Parser, Renderer, @-Autocomplete
|
|
calendarUtils.ts Datums-, Raster- und Layoutlogik
|
|
leads/
|
|
Leads.tsx Suche, Statusfilter, Ansichtswechsel
|
|
LeadGrid.tsx Kachelansicht
|
|
LeadList.tsx Listenansicht (sortierbar)
|
|
LeadNetwork.tsx Interaktives Netzwerkdiagramm (d3-force + SVG)
|
|
LeadModal.tsx Kontaktformular inkl. Verbindungspflege
|
|
leadUi.tsx Statusfarben, Eingabestile, Avatar
|
|
scanner/
|
|
ScannerDashboard.tsx Kennzahlen, Filter, Match-Tabelle, Signalton
|
|
ScannerRejected.tsx Reiter Unpassend (status = rejected)
|
|
ScannerAgents.tsx Suchagenten inkl. CV-Upload
|
|
ScannerPortals.tsx Börsen-Konten, Cookie-Import, Verbindungstest
|
|
MatchDetailModal.tsx Detailfenster eines Treffers (beide Listen)
|
|
scannerUi.tsx Prozent-Badge, Status-Pillen, Formatierung
|
|
settings/
|
|
Settings.tsx Tabs Nutzerverwaltung / Mein Konto / Scanner
|
|
UserManagement.tsx Benutzer anlegen, bearbeiten, löschen
|
|
ScannerSettings.tsx Alle Einstellungen des Projekt Scanners
|
|
ColorPicker.tsx Auswahl der persoenlichen Kalenderfarbe
|
|
lib/scannerApi.ts Typisierte Endpunkte unter /api/scanner
|
|
server/
|
|
src/server.js Express-App
|
|
src/db.js Pool + Schema-Init + Admin-Seed
|
|
src/schema.sql Idempotentes Schema
|
|
src/middleware/auth.js JWT-Prüfung, Admin-Guard
|
|
src/userFields.js Prüfung von Farbe und Foto (von auth und users genutzt)
|
|
src/routes/ auth, users, leads, connections, events, opportunities,
|
|
scanner
|
|
src/scanner/ Portierter Projekt Scanner: Sequelize-Modelle,
|
|
Scraper (Playwright), Matching (OpenAI), Scheduler
|
|
deploy/
|
|
perinco-crm-backend.service systemd-Unit
|
|
pcrm.perinco.de.conf Apache-Vhost
|
|
```
|
|
|
|
## Opportunities
|
|
|
|
Erster Reiter im Menü und Startansicht. Drei Ansichten wie bei den Kontakten,
|
|
umgeschaltet oben rechts:
|
|
|
|
| Ansicht | Inhalt |
|
|
| --- | --- |
|
|
| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und nächstem Schritt |
|
|
| **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um |
|
|
| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
|
|
|
|
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer
|
|
Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen
|
|
mag oder auf dem Tablet arbeitet, ändert die Phase über das Auswahlfeld auf der
|
|
Karte.
|
|
|
|
**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste
|
|
aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in
|
|
Personentagen (halbe Tage möglich), Projektstart und -ende, letzte Interaktion
|
|
mit Notiz, nächster Schritt, Tags, interne Notizen, Zuständigkeit sowie
|
|
beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle.
|
|
|
|
**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusätzlich
|
|
mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe
|
|
über der Liste, je Pipeline-Spalte und in der Kopfzeile.
|
|
|
|
**Sortieren** geht doppelt: über das Auswahlfeld in der Steuerleiste (wirkt in
|
|
allen Ansichten) und in der Liste über die Spaltenköpfe. Geld- und Datumsfelder
|
|
starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
|
|
|
|
Ändern und löschen darf der Ersteller, der Zuständige und jeder Administrator.
|
|
|
|
## Projekt Scanner
|
|
|
|
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll
|
|
die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`:
|
|
|
|
```
|
|
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
|
|
/var/www/Projekt Scanner/frontend/dist DocumentRoot des Vhosts
|
|
systemd-Unit: project-scanner-backend (nicht "project-scanner")
|
|
```
|
|
|
|
Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene
|
|
Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis,
|
|
eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch
|
|
ihre Datenbank werden angefasst.
|
|
|
|
Menüpunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
|
|
auf:
|
|
|
|
| Reiter | Inhalt |
|
|
| --- | --- |
|
|
| **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* |
|
|
| **Unpassend** | als „passt nicht" markierte Treffer; zurückholen oder löschen |
|
|
| **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv |
|
|
| **Börsen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
|
|
|
|
Die **Einstellungen** des Scanners stehen nicht im Untermenü, sondern als Reiter
|
|
*Scanner* unter *Einstellungen* - also nur für Rolle `admin`. Die Reiter selbst
|
|
sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an
|
|
beiden Stellen ändern; es ist derselbe Wert in der Datenbank.
|
|
|
|
### Was portiert wurde
|
|
|
|
| Teil | Wohin |
|
|
| --- | --- |
|
|
| Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` |
|
|
| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` |
|
|
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
|
|
| Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
|
|
| Übernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
|
|
|
|
Drei bewusste Unterschiede zum Original:
|
|
|
|
1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfällt - alle Endpunkte
|
|
liegen hinter `requireAuth`, also der CRM-Sitzung.
|
|
2. **CV-PDFs** kommen über `GET /api/scanner/agents/:id/cv` statt aus einem
|
|
statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token
|
|
mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
|
|
3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog.
|
|
|
|
### Parallelbetrieb - alles doppelt
|
|
|
|
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu berühren.
|
|
Dafür ist hier jede geteilte Ressource verdoppelt:
|
|
|
|
| | alte App | diese Instanz |
|
|
| --- | --- | --- |
|
|
| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) |
|
|
| Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
|
|
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` |
|
|
| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` |
|
|
| Aufräumen | 03:00 | 03:30 |
|
|
|
|
Die Scanner-Datenbank legt das Backend beim Start **selbst an**
|
|
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
|
|
Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
|
|
zu starten.
|
|
|
|
| Variable | Bedeutung |
|
|
| --- | --- |
|
|
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen |
|
|
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
|
|
| `SCANNER_ENCRYPTION_KEY` | eigener Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
|
|
| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` |
|
|
| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab |
|
|
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den **allerersten** Lauf, Standard `30`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg |
|
|
|
|
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhängig
|
|
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App
|
|
> läuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
|
|
> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert
|
|
> dann weiter.
|
|
|
|
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben
|
|
Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start
|
|
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
|
|
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den
|
|
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt.
|
|
|
|
Ab dem zweiten Start trägt `settings.last_scan_at` den Zeitplan - die Frist
|
|
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
|
|
fälliger Lauf konnte beliebig lange ausfallen (siehe
|
|
[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)).
|
|
|
|
Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler
|
|
und *Jetzt scannen* teilen sich eine Sperre (`isPipelineRunning`), ein zweiter
|
|
Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashboard
|
|
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
|
|
pausiert, Suchlauf läuft - samt Namen der Datenbank.
|
|
|
|
### Daten aus der alten App übernehmen
|
|
|
|
Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale
|
|
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
|
|
ausschließlich in `perinco_scanner`:
|
|
|
|
```bash
|
|
cd /var/www/perinco_CRM/server
|
|
npm run scanner:import -- --dry-run # nur anzeigen
|
|
npm run scanner:import # Einstellungen, Portale, Agenten + CVs
|
|
npm run scanner:import -- --with-matches # zusätzlich Projekte und Treffer
|
|
npm run scanner:import -- --force # vorhandene Zieldaten vorher löschen
|
|
```
|
|
|
|
Quelle kommt aus `LEGACY_*` in `server/.env`:
|
|
|
|
| Variable | Wofür |
|
|
| --- | --- |
|
|
| `LEGACY_DB_NAME` | Standard `project_scanner` |
|
|
| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
|
|
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anführungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
|
|
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwörter übernehmen: entschlüsseln mit dem alten, neu verschlüsseln mit dem eigenen Schlüssel |
|
|
|
|
Übernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
|
|
DB) - der neue Scanner ist damit sofort scharf.
|
|
|
|
Agenten, Projekte und Treffer behalten ihre Ids, damit übernommene Treffer
|
|
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
|
|
ab, wenn im Ziel schon Agenten stehen - `--force` räumt vorher auf.
|
|
|
|
> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank
|
|
> gespeicherten Portal-Passwörter lassen sich mit dem dortigen
|
|
> `ENCRYPTION_KEY` **nicht** mehr entschlüsseln - der Schlüssel wurde nach dem
|
|
> Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging
|
|
> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die
|
|
> Passwörter unter *Börsen-Konten* neu ein. Die Sessions (Cookies) sind davon
|
|
> unberührt und wurden übernommen.
|
|
|
|
### Playwright
|
|
|
|
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende
|
|
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits
|
|
in `/root/.cache/ms-playwright`, also fällt kein zweiter Browser-Download an und
|
|
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der
|
|
Version würde beides aufgeben, deshalb steht in `server/package.json` ein
|
|
`comments`-Feld mit dieser Begründung.
|
|
|
|
## Diktat (Whisper im Browser)
|
|
|
|
Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
|
|
nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
|
|
läuft **vollständig im Browser des Benutzers**: Whisper als ONNX-Modell über
|
|
`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder
|
|
ersatzweise auf der CPU (WASM).
|
|
|
|
```
|
|
MediaRecorder ──► AudioContext (16 kHz Mono) ──► Web Worker ──► Whisper (WebGPU/WASM)
|
|
```
|
|
|
|
Das Backend ist daran **nicht beteiligt** und sieht keine Audiodaten - es gibt
|
|
keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen
|
|
werden nirgends gespeichert.
|
|
|
|
* Die Erkennung läuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
|
|
damit die Oberfläche währenddessen nicht einfriert.
|
|
* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die
|
|
**Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben
|
|
danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den
|
|
eigenen Server legen und `env.localModelPath` setzen.
|
|
|
|
### Reiter "Whisper"
|
|
|
|
Unten im Menü, über den Einstellungen, für **alle Rollen** sichtbar. Es gibt
|
|
dort **nichts zu konfigurieren** - genau ein Knopf:
|
|
|
|
1. Reiter öffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."*
|
|
2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten
|
|
3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch
|
|
nach jeder weiteren Anmeldung
|
|
|
|
Dazu gibt es eine Probeaufnahme und **Sprachmodell löschen**, falls man den
|
|
Platz zurückhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
|
|
kein Rechenweg, keine Gerätetabelle.
|
|
|
|
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
|
|
Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden,
|
|
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
|
|
kaputt (siehe unten); eine Auswahl hätte also nur Gelegenheit geboten, etwas
|
|
falsch zu machen.
|
|
|
|
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
|
|
diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der
|
|
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell löscht,
|
|
bekommt es nicht sofort wieder heruntergeladen.
|
|
|
|
### Wo das Modell liegt und wie lange
|
|
|
|
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
|
|
**Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der
|
|
Sitzungen und Browserneustarts überlebt. Beim Laden bittet die App zusätzlich
|
|
per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser
|
|
die Dateien bei Platznot nicht mehr selbst wegräumen. Chrome entscheidet darüber
|
|
nach eigenen Kriterien und lehnt häufig ab - dann funktioniert alles genauso, nur
|
|
ohne diese Zusicherung.
|
|
|
|
Gerät oder Browserprofil gewechselt? Dann fällt der Download erneut an; der
|
|
Speicher gehört zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
|
|
nach dem Laden nicht im Cache, sagt sie es ausdrücklich.
|
|
|
|
### Warum small und q4 - die Messungen
|
|
|
|
Die folgenden Zahlen sind der Grund, warum es keine Auswahl gibt. Gemessen mit
|
|
derselben deutschen Testaufnahme (Windows-Sprachausgabe, also schwieriger als
|
|
eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4:
|
|
|
|
| Modell | Erkennung des Satzes | Download | Laden |
|
|
| --- | --- | --- | --- |
|
|
| tiny | "auf es vier Hanna", "Der Tag ist satscht bei L-10 Euro" | 99 MB | 4 s |
|
|
| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s |
|
|
| **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s |
|
|
|
|
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geräten ohne WebGPU
|
|
verschiebt sich das Tempo erheblich.
|
|
|
|
Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie
|
|
"S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB
|
|
fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der
|
|
Seite und überlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne
|
|
jeden Download bereit).
|
|
|
|
`small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht
|
|
zwischengespeichert (siehe Größengrenzen unten) und lädt darum bei jedem Start
|
|
erneut 759 MB.
|
|
|
|
### Welche Genauigkeit auf welchem Rechenweg
|
|
|
|
Deshalb ist `q4` festgelegt:
|
|
|
|
| | q4 | q8 | fp16 | fp32 |
|
|
| --- | --- | --- | --- | --- |
|
|
| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny läuft, small blockiert den Tab | Einzeldatei zu groß |
|
|
| **WASM** | läuft | small 10,3 s | ungetestet | Einzeldatei zu groß |
|
|
|
|
Der wichtigste Grund für diese Beschränkung: bei `small` in fp16 auf WebGPU
|
|
reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das
|
|
abfangen**, weil das Skript selbst nicht mehr läuft.
|
|
|
|
Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese
|
|
Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro
|
|
Erkennung und eine Hänger-Erkennung beim Laden - falls ein Gerät trotzdem etwas
|
|
Unerwartetes tut.
|
|
|
|
Zusätzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
|
|
höchstens 5 min). Läuft sie ab, wird der Worker verworfen und der Fehler
|
|
gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
|
|
|
|
### Die Größengrenze des Browsers
|
|
|
|
Nicht die Gesamtgröße entscheidet, sondern die **größte Einzeldatei** - der
|
|
Browser muss sie in einem zusammenhängenden Speicherblock halten. Über etwa
|
|
500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB
|
|
(turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern.
|
|
|
|
Genau daran scheitern für turbo alle Genauigkeiten ausser q4 - ein weiterer
|
|
Grund, warum es keine Auswahl gibt.
|
|
|
|
Die Downloadgröße im Reiter kommt live von Hugging Face
|
|
(`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht.
|
|
|
|
> **Korrektur:** In einer früheren Fassung stand hier eine zweite Grenze von
|
|
> etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht
|
|
> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung.
|
|
> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht,
|
|
> ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit
|
|
> 762 MB im Cache und überlebt einen Browserneustart. Deshalb prüft
|
|
> `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes
|
|
> Chrome und nicht gegen den Automatisierungskontext.
|
|
|
|
> **Version festgehalten:** `@huggingface/transformers` ist auf `^3.8.1`
|
|
> gepinnt. Version 4.2.0 zieht eine andere ONNX-Laufzeit (`asyncify` statt
|
|
> `jsep`, letztere ist die WebGPU-Variante) - vor einem Wechsel also erst die
|
|
> Modelle real gegentesten.
|
|
|
|
### Wenn das Modell fehlt
|
|
|
|
Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit
|
|
Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat
|
|
einrichten*. Beides schließt den Dialog und springt in den Reiter. Auf Geräten
|
|
ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat.
|
|
|
|
## Kalender
|
|
|
|
Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf
|
|
Einträge anlegen.
|
|
|
|
* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blättern. Klick auf
|
|
einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf
|
|
einen Wochentagskopf springt in die Tagesansicht.
|
|
* **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus
|
|
`crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID
|
|
abgeleitet (Palette in `src/types.ts`). Änderbar unter *Einstellungen → Mein
|
|
Konto* und - für Admins für alle - in der Nutzerverwaltung. Die Legende
|
|
unter dem Kalender zeigt, welche Farbe zu wem gehört.
|
|
* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen
|
|
mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer
|
|
Kalenderfarbe - und sind anklickbar.
|
|
* **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu
|
|
markieren (Pfeiltasten, Enter/Tab zum Übernehmen). Gespeichert wird ein Token
|
|
`@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht
|
|
sind Mentions klickbar: Kontakte öffnen das Kontaktfenster, Benutzer ein
|
|
Detailfenster mit Farbe, nächsten Terminen und zugeordneten Kontakten.
|
|
Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen.
|
|
* **Nächste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
|
|
zeitlich sortiert - der nächste anstehende Termin steht oben. Vergangene
|
|
Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
|
|
|
|
## Fotos
|
|
|
|
Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank
|
|
(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufbläht,
|
|
verkleinert der Browser jedes Bild **vor** dem Upload
|
|
(`src/lib/imageCompress.ts`):
|
|
|
|
* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht,
|
|
die größte ist 80 px
|
|
* WebP mit Qualität 0,82, Fallback JPEG, dann PNG
|
|
* aus einem 2,9-MB-Foto werden so einige Kilobyte
|
|
|
|
Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
|
|
Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin
|
|
komprimiert wird.
|
|
|
|
Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein
|
|
Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
|
|
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
|
|
@-Vorschlägen, in der Nutzerverwaltung und als Benutzerknoten im
|
|
Netzwerkdiagramm. Ohne Foto erscheint überall die Initiale auf der
|
|
Kalenderfarbe (`UserAvatar`).
|
|
|
|
Überlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtägige
|
|
Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die
|
|
aktuelle Uhrzeit.
|
|
|
|
## Netzwerkansicht
|
|
|
|
Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
|
|
|
|
* **Kreise** = Kontakte, Farbe nach Status, Größe nach Anzahl Verbindungen
|
|
* **Quadrate** = Benutzer, die Kontakte angelegt haben
|
|
* **Grüne Linie** = Kontakt ↔ Kontakt (Dicke nach Verbindungsstärke)
|
|
* **Graue gestrichelte Linie** = Ersteller → Kontakt
|
|
|
|
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor,
|
|
Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung.
|
|
Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
|
|
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
|
|
|
|
## Lokale Entwicklung
|
|
|
|
```bash
|
|
npm install
|
|
cd server && npm install && cp .env.example .env # Werte anpassen
|
|
npm start # Backend :5006
|
|
cd .. && npm run dev # Frontend :3100, /api wird geproxied
|
|
```
|
|
|
|
Für lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
|
|
`perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die
|
|
Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive
|
|
Tabellen, Portalen und Standardeinstellungen.
|
|
|
|
## Deployment
|
|
|
|
Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen -
|
|
der Server hat wenig freien RAM und volles Swap.
|
|
|
|
```powershell
|
|
npm run build
|
|
powershell -File deploy/deploy.ps1 # lädt dist/ und server/src hoch
|
|
powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm install auf dem Server
|
|
```
|
|
|
|
Ein Rollout mit neuen Backend-Paketen braucht `-Deps`.
|
|
|
|
### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt)
|
|
|
|
```bash
|
|
# 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte
|
|
# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an
|
|
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
|
|
|
|
# 2. Bestand übernehmen
|
|
cd /var/www/perinco_CRM/server
|
|
npm run scanner:import -- --dry-run
|
|
npm run scanner:import -- --with-matches
|
|
|
|
# 3. MemoryMax anheben und neu starten
|
|
cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/
|
|
systemctl daemon-reload && systemctl restart perinco-crm-backend
|
|
journalctl -u perinco-crm-backend -n 40
|
|
```
|
|
|
|
Die Datenbank `perinco_scanner` muss **nicht** von Hand angelegt werden, das
|
|
erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe
|
|
Abschnitt *Playwright* oben.
|
|
|
|
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
|
|
> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die
|
|
> Änderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
|
|
> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im
|
|
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
|
|
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
|
|
|
|
Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
|
|
Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
|
|
Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
|
|
daher der `cp` oben.
|
|
|
|
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
|
|
> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET`
|
|
> verschiebt die Läufe gegeneinander, garantiert aber nichts - der alte Dienst
|
|
> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap
|
|
> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
|
|
|
|
Manuell entspricht das:
|
|
|
|
```powershell
|
|
pscp -r -P 4345 dist root@212.227.135.41:/var/www/perinco_CRM/
|
|
pscp -r -P 4345 server\src root@212.227.135.41:/var/www/perinco_CRM/server/
|
|
pscp -P 4345 server\package.json root@212.227.135.41:/var/www/perinco_CRM/server/
|
|
plink -P 4345 root@212.227.135.41 "systemctl restart perinco-crm-backend"
|
|
```
|
|
|
|
### Serverseitige Einrichtung (einmalig, bereits erledigt)
|
|
|
|
```bash
|
|
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS perinco_CRM
|
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
mkdir -p /var/www/perinco_CRM/server
|
|
# .env anlegen (chmod 600), Abhaengigkeiten installieren
|
|
cd /var/www/perinco_CRM/server && npm install --omit=dev
|
|
cp deploy/perinco-crm-backend.service /etc/systemd/system/
|
|
systemctl daemon-reload && systemctl enable --now perinco-crm-backend
|
|
cp deploy/pcrm.perinco.de.conf /etc/apache2/sites-available/
|
|
a2ensite pcrm.perinco.de && apache2ctl configtest && systemctl reload apache2
|
|
```
|
|
|
|
### HTTPS (offen - braucht DNS)
|
|
|
|
Sobald `pcrm.perinco.de` per A-Record auf `212.227.135.41` zeigt:
|
|
|
|
```bash
|
|
certbot --apache -d pcrm.perinco.de
|
|
```
|
|
|
|
certbot legt den `:443`-Block selbst an. Bei anderer Subdomain den `ServerName`
|
|
in `deploy/pcrm.perinco.de.conf` und den certbot-Aufruf anpassen.
|
|
|
|
## Zugang
|
|
|
|
Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in
|
|
`server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist
|
|
danach ausschließlich über die Nutzerverwaltung bzw. *Mein Konto* änderbar.
|