Files
perinco-CRM/README.md
T
Sascha WolfandClaude Opus 5 3c9d83161e chore(scanner): alte Scanner-App zurueckgebaut
Die eigenstaendige Vorgaenger-App unter scanner.perinco.de ist auf dem Server
entfernt: Unit project-scanner-backend (Port 5005), beide Apache-Vhosts, das
Let's-Encrypt-Zertifikat samt Renewal-Config, /var/www/Projekt Scanner (486 MB)
und die Datenbank project_scanner (Dump unter /root/backups). Damit entfallen
die LEGACY_*-Variablen und das Importskript, dessen Quelle es nicht mehr gibt.

/root/.cache/ms-playwright bleibt bewusst stehen: der Cache stammt aus der
Installation der alten App, liegt aber ausserhalb beider Verzeichnisse und wird
vom CRM-Scanner benutzt - playwright ist auf 1.60.0 gepinnt, damit es genau
dieser Build (chromium-1223) bleibt. Der Hinweis steht jetzt in README und
docs/06-betrieb.md, damit ihn beim naechsten Aufraeumen niemand mitnimmt.

SCANNER_SCHEDULER_OFFSET geht auf 0: der Versatz sollte verhindern, dass beide
Instanzen mit denselben Portal-Cookies gleichzeitig losziehen. Ohne zweite
Instanz hat er keinen Zweck mehr, die Mechanik bleibt fuer kuenftige Erststarts
im Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:45:42 +02:00

635 lines
31 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.
![Opportunities im perinco CRM](docs/bilder/opportunities-kacheln.png)
*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.
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 &harr; Kontakt (Dicke nach Verbindungsstärke)
* **Graue gestrichelte Linie** = Ersteller &rarr; 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, 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.