# 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 `` 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 = `/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 ↔ Kontakt (Dicke nach Verbindungsstärke) * **Graue gestrichelte Linie** = Ersteller → Kontakt Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor, Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung. Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen; Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter. ## Lokale Entwicklung ```bash npm install cd server && npm install && cp .env.example .env # Werte anpassen npm start # Backend :5006 cd .. && npm run dev # Frontend :3100, /api wird geproxied ``` Für lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank `perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive Tabellen, Portalen und Standardeinstellungen. ## Deployment Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen - der Server hat wenig freien RAM und volles Swap. ```powershell npm run build powershell -File deploy/deploy.ps1 # lädt dist/ und server/src hoch powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm install auf dem Server ``` Ein Rollout mit neuen Backend-Paketen braucht `-Deps`. ### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt) ```bash # 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, 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.