Sascha WolfandClaude Opus 5.5 69001e9687 feat(scanner): Schwellen im Dashboard pro Benutzer
"Anzeige ab %" und "Hinweis ab %" liegen jetzt am CRM-Benutzer
(crm_users.scanner_display_threshold/scanner_match_threshold) statt global
in den Scanner-Einstellungen. NULL = globaler Standard. Die Mail-Schwelle
bleibt global, die KI-Suche nimmt die Anzeigeschwelle des Suchenden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:50:40 +01:00

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

Beispieldaten - Firmen, Personen und Zahlen in allen Abbildungen sind erfunden. Die Bildstrecke aller Bereiche steht in der Anleitung für Benutzer.

Dokumentation mit Funktionszeichnungen: docs/ — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb, Postfachanbindung. Zum Einstieg: Anleitung für Benutzer · Anleitung für Entwickler · Quellcode-Referenz

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 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).

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

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.

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)

# 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:

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)

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:

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.

S
Description
perinco CRM mit portiertem Projekt Scanner - Vite/React, Express, MariaDB
Readme
6 MiB
Languages
TypeScript 63.4%
JavaScript 33.2%
CSS 2.9%
HTML 0.2%
Shell 0.2%
Other 0.1%