Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis, waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist der Code jetzt versioniert. Globale Suche: - server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und Akquise, dazu die erweiterte Suche mit dem Modell des Scanners - src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer springt in die Ansicht und oeffnet den Datensatz (navigation.tsx) ICON CRM: - server/src/routes/integrations.js liefert die Kontakte mit gesetztem Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von drueben herein - beide nur lesend, mit festem Token statt JWT - server/src/contactBridge.js und src/lib/contactMatch.ts erkennen Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie zusammenfuehren - server/src/ai.js buendelt die OpenAI-Aufrufe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
700 lines
34 KiB
Markdown
700 lines
34 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.
|
|
|
|

|
|
|
|
*Beispieldaten - Firmen, Personen und Zahlen in allen Abbildungen sind erfunden.
|
|
Die Bildstrecke aller Bereiche steht in der
|
|
[Anleitung für Benutzer](docs/07-anleitung-benutzer.md).*
|
|
|
|
> **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 hat die
|
|
alte App unter `scanner.perinco.de` am **09.09.2026** ersetzt. Seither läuft nur
|
|
noch diese Instanz; der Rückbau ist in
|
|
[docs/03-scanner.md](docs/03-scanner.md#rückbau-der-alten-app---09092026)
|
|
protokolliert.
|
|
|
|
Menüpunkt **Scanner** links; ein Klick klappt die Reiter 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 |
|
|
|
|
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.
|
|
|
|
### Eigene Ressourcen, eigener Zeitplan
|
|
|
|
Der Scanner benutzt durchweg Eigenes und nichts aus der Vorgänger-App - das war
|
|
die Voraussetzung für den Parallelbetrieb und macht den Rückbau folgenlos:
|
|
|
|
| | alte App | diese Instanz |
|
|
| --- | --- | --- |
|
|
| Datenbank | `project_scanner` (gedroppt) | `perinco_scanner` |
|
|
| Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
|
|
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` (gelöscht) | `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 |
|
|
|
|
Geteilt war genau eine Sache, und die liegt ausserhalb beider Verzeichnisse:
|
|
`/root/.cache/ms-playwright` mit dem Build `chromium-1223`. Der Cache ist beim
|
|
Rückbau bewusst stehen geblieben, siehe Abschnitt *Playwright*.
|
|
|
|
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` |
|
|
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
|
|
| `SCANNER_ENCRYPTION_KEY` | Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
|
|
| `SCANNER_UPLOAD_DIR` | 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, seit dem Rückbau `0`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg |
|
|
|
|
Wer nur zuschauen will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt
|
|
scannen* funktioniert dann weiter.
|
|
|
|
Der **Zeitversatz** war nicht Kosmetik: beide Instanzen benutzten dieselben
|
|
Portal-Sessions und wären beim ersten Start gleichzeitig losgezogen - das
|
|
provoziert Rate-Limits und CAPTCHAs bei Freelancermap und Freelance.de. Mit dem
|
|
Rückbau steht der Wert auf 0; die Mechanik bleibt im Code, weil sie bei jedem
|
|
Erststart einer weiteren Instanz wieder greift. Ab dem zweiten Start trägt
|
|
`settings.last_scan_at` den Zeitplan - sonst begann die Frist 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.
|
|
|
|
### Was aus der alten App wurde
|
|
|
|
Beim Rollout am 06.08.2026 hat ein Skript (`importLegacy.js`,
|
|
`npm run scanner:import`) Agenten, Portale, Einstellungen und Session-Cookies aus
|
|
`project_scanner` herübergeholt; die CV-PDFs wurden in das eigene
|
|
Upload-Verzeichnis kopiert. Mit dem Rückbau am 09.09.2026 sind Quelle und Skript
|
|
weg.
|
|
|
|
> **Damals festgestellt:** Die in der alten Datenbank gespeicherten
|
|
> Portal-Passwörter liessen sich mit dem dortigen `ENCRYPTION_KEY` **nicht** mehr
|
|
> entschlüsseln - der Schlüssel war nach dem Speichern getauscht worden. Die alte
|
|
> App konnte sie damit ebenso wenig lesen, es ging also nichts verloren. Die
|
|
> Sessions (Cookies) lagen im Klartext und wurden übernommen.
|
|
|
|
Nicht mitgekommen sind 13380 Treffer, die es nur in der alten Datenbank gab,
|
|
darunter 92 als *reviewed* und 5 als *applied* markierte. Ein Nachimport hätte
|
|
mit den vorhandenen Ids in `perinco_scanner` kollidiert, und der Bestand war
|
|
ohnehin flüchtig - der Aufräumjob hält nur die jüngsten Tage. Ein Dump liegt
|
|
unter `/root/backups/project_scanner_20260909-173447.sql.gz`.
|
|
|
|
### Playwright
|
|
|
|
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
|
|
(`chromium-1223`) liegt auf dem Server 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.
|
|
|
|
> **Nicht mit aufräumen:** Dieser Cache (641 MB) stammt aus der Installation der
|
|
> alten Scanner-App, liegt aber ausserhalb beider Anwendungsverzeichnisse und
|
|
> wurde von beiden benutzt - `PLAYWRIGHT_BROWSERS_PATH` ist nirgends gesetzt.
|
|
> Beim Rückbau am 09.09.2026 ist er deshalb bewusst stehen geblieben. Wer ihn
|
|
> löscht, nimmt dem Scanner den Browser; Reparatur:
|
|
> `cd /var/www/perinco_CRM/server && npx playwright install chromium`.
|
|
|
|
## 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.
|
|
|
|
### Profilfoto von LinkedIn
|
|
|
|
Trägt man beim Anlegen eines Kontakts eine LinkedIn-Adresse ein, holt das CRM
|
|
das öffentliche Profilbild und setzt es als Kontaktfoto - beim Verlassen des
|
|
Feldes von selbst (einmal je Adresse, nur solange kein Foto gesetzt ist) oder
|
|
auf Klick über den Knopf *Foto* daneben.
|
|
|
|
Den Abruf macht das Backend (`server/src/linkedinPhoto.js`, Route
|
|
`POST /api/leads/linkedin-photo`): aus dem Browser heraus stünde die
|
|
Same-Origin-Policy davor. Gelesen wird die Open-Graph-Vorschau der öffentlichen
|
|
Profilseite (`og:image`), das Bild kommt anschließend von `media.licdn.com`.
|
|
Geprüft wird die Herkunft (nur `https` von `licdn.com` mit `/dms/image/` im
|
|
Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s). Verkleinert wird danach
|
|
im Browser über denselben Weg wie ein Upload (`compressImageSource()`).
|
|
|
|
Gelesen wird **ausschließlich** die Vorschau-Adresse. Der übrige Quelltext wird
|
|
nicht durchsucht, obwohl dort Dutzende Bildadressen stehen - sie gehören
|
|
Firmenlogos, Beiträgen und den „ähnlichen Profilen" am Seitenrand. Eine davon zu
|
|
nehmen, hieße, einem Kontakt das Gesicht einer fremden Person anzuhängen.
|
|
|
|
**Wann kein Foto kommt:** Liefert die Vorschau die graue Silhouette oder eine
|
|
Grafik von `static.licdn.com`, gibt LinkedIn zu diesem Profil öffentlich kein
|
|
Foto heraus. Zwei Ursachen, von außen nicht unterscheidbar: Das Profil hat kein
|
|
Foto, oder seine Sichtbarkeit steht auf *Meine Kontakte* statt *Alle
|
|
LinkedIn-Mitglieder*. Im zweiten Fall sieht man das Foto im eigenen,
|
|
angemeldeten LinkedIn sehr wohl - dieser Abruf ist aber nicht angemeldet, und
|
|
das soll er auch nicht sein.
|
|
|
|
Der Abruf ist bewusst als **Komfort** gebaut, nicht als Zusage: LinkedIn weist
|
|
Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er
|
|
fehl, steht der Grund unter dem Feld und das Foto wird wie bisher hochgeladen.
|
|
|
|
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.
|
|
|
|
Die Art einer bestehenden Verbindung lässt sich sowohl im Kontaktdialog als auch
|
|
in der Seitenleiste der Netzwerkansicht direkt umstellen
|
|
(`PATCH /api/connections/:id`, ohne Speichern-Knopf). Vorher musste man sie
|
|
löschen und neu anlegen.
|
|
|
|
Ein Klick auf einen Knoten hebt ihn samt Nachbarn hervor und blendet den Rest
|
|
auf 22 % Deckkraft ab. Ein Klick auf eine **freie Fläche** hebt die Auswahl
|
|
wieder auf - der Ausgangszustand der Ansicht. Damit das Verschieben nicht
|
|
versehentlich die Auswahl verwirft, gilt eine Geste erst ab 4 px Weg als Ziehen;
|
|
Knoten stoppen das `pointerdown` ohnehin, ihre Klicks erreichen den Hintergrund
|
|
nicht.
|
|
|
|
## Anbindung an das ICON CRM
|
|
|
|
Beide Häuser tauschen ihre Kontakte lesend aus - gekennzeichnet, nicht
|
|
vermischt, und **ohne dass etwas gespeichert wird**. Ein nächtlicher Abgleich
|
|
wäre der falsche Weg: dann stünde derselbe Mensch in zwei Systemen.
|
|
|
|
| Richtung | Weg |
|
|
| --- | --- |
|
|
| hinaus | `GET /api/integrations/contacts` - liefert die Kontakte mit gesetztem Häkchen *Im ICON CRM anzeigen* (`crm_leads.icon`). Token: `ICON_API_TOKEN` |
|
|
| herein | `GET /api/icon/contacts` - holt die Kontakte des ICON CRM. `ICON_CRM_URL`, `ICON_CRM_TOKEN`, eine Minute Zwischenspeicher |
|
|
|
|
Beide Wege kennen nur `GET`, beide nutzen einen festen Token statt JWT: der
|
|
Aufrufer ist das jeweils andere Backend, kein angemeldeter Mensch.
|
|
|
|
In den Kontakten blendet das Häkchen **ICON** die Kontakte von drüben ein -
|
|
**gold umrandet** und ohne Bearbeitung. Umgekehrt zeigt das ICON CRM die
|
|
hiesigen Kontakte grün umrandet an.
|
|
|
|
Kontakte, die hier das Häkchen tragen, sind in der Liste an der Marke **ICON**
|
|
neben der Kontaktart zu erkennen.
|
|
|
|
## 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, legt vorher ein .env.bak an
|
|
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
|
|
|
|
# 2. 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. Der damalige Schritt *Bestand übernehmen*
|
|
(`npm run scanner:import`) ist mit dem Rückbau der alten App entfallen.
|
|
|
|
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.
|
|
|
|
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.
|