Dokumentation: README.md
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
# ICON CRM
|
||||
|
||||
CRM im ICON Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
|
||||
Läuft auf <https://icrm.icon-experts.de>.
|
||||
|
||||
Der Aufbau folgt bewusst dem perinco CRM: gleiche Architektur, gleiche
|
||||
Deploy-Wege, gleiche Muster für Theme, Dialoge, Erwähnungen und Netzwerkansicht.
|
||||
Wer das eine kennt, findet sich im anderen zurecht.
|
||||
|
||||
## Corporate Identity
|
||||
|
||||
Das Favicon ist die kleine Wortmarke des Hauses, erzeugt aus
|
||||
`Draw/Logo klein.png`: `public/favicon.ico` (16/32/48/64 px) und
|
||||
`public/favicon.png` (180 px) für den Startbildschirm. Bewusst **keine** SVG:
|
||||
die `favicon.svg` der Website ist der violette Blitz des Gerüsts und gehört
|
||||
nicht zu ICON.
|
||||
|
||||
| Rolle | Wert |
|
||||
| --- | --- |
|
||||
| Primärfarbe (Gold) | `#C1862F`, Akzent `#DFA85A` |
|
||||
| Gefüllte Knöpfe | `#A06C22` (`brand-600`) – weiße Schrift braucht den dunkleren Ton |
|
||||
| Flächen | `#F6F6F7` hell, `#0A0A0A` dunkel |
|
||||
| Schrift | Montserrat |
|
||||
|
||||
Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`)
|
||||
sowie die Flächen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
|
||||
|
||||
## Designs
|
||||
|
||||
Rechts oben im Kopf stehen zwei Umschalter: ein Auswahlfeld für die Farbwelt und
|
||||
ein Knopf für hell/dunkel. Beides gilt für den angemeldeten Benutzer – es steht
|
||||
an seinem Konto, nicht nur im Browser.
|
||||
|
||||
| Design | Beschreibung |
|
||||
| --- | --- |
|
||||
| **ICON** | Das Design des Hauses – Gold `#C1862F` auf neutralem Grau, Montserrat. Bewusst ohne Grafik: das Corporate Design bleibt sachlich |
|
||||
| **Half-Life** | Black Mesa: HEV-Orange auf Sichtbeton, fast rechtwinklige Ecken, harte versetzte Schatten, Überschriften in Oswald |
|
||||
| **Pixel Art** | 8-bit: keine Radien, versetzte Schatten ohne Weichzeichnung, Arcade-Grün, Überschriften in Silkscreen |
|
||||
|
||||
Half-Life und Pixel Art bringen zusätzlich **Grafiken** hinter der Kopfleiste
|
||||
(`.app-header`) und dem Menü (`.app-sidebar`) mit – als SVG unter
|
||||
`public/themes/`. Die Variable enthält dabei **zwei** Ebenen: einen Schleier aus
|
||||
der Flächenfarbe und darunter die Zeichnung. Das ist kein Umweg, sondern der
|
||||
Grund, dass dieselbe Grafik in hell und dunkel funktioniert – und die Bedingung
|
||||
dafür, dass die Schrift lesbar bleibt: in der Kopfleiste liegt der Schleier an
|
||||
den Rändern dicht und lässt die Grafik nur in der Mitte durch, im Menü liegt er
|
||||
oben über den Einträgen und gibt nach unten frei.
|
||||
|
||||
Ein Design tauscht ausschließlich Variablenwerte – im Markup steht nirgends eine
|
||||
feste Farbe. Deshalb kommen Half-Life und ICON mit derselben Oberfläche aus.
|
||||
|
||||
Zwei Entscheidungen zu Half-Life, weil sie sonst als Versehen aussähen:
|
||||
|
||||
* Der Akzent bleibt **Orange und wird nicht zu Warngelb**. Auf `brand-500` und
|
||||
`brand-600` steht überall weiße Schrift; auf Gelb wäre sie nicht mehr lesbar.
|
||||
Das Gelb-Schwarz der Gefahrenstreifen kommt deshalb nicht als Fläche vor.
|
||||
* **Oswald trägt nur die Überschriften und Feldbeschriftungen**, gesperrt in
|
||||
Versalien. Der Fließtext bleibt Montserrat: die Tabellen haben bis zu acht
|
||||
Spalten bei 12 px, und eine Schmalschrift wäre darin nicht mehr zu lesen.
|
||||
Dasselbe gilt für Silkscreen in Pixel Art.
|
||||
|
||||
Die Liste der Designs steht an zwei Stellen und muss übereinstimmen:
|
||||
`THEME_PALETTES` in `src/types.ts` und `THEMES` in `server/src/userFields.js`.
|
||||
Das Backend weist einen unbekannten Wert mit 400 ab, damit ein Tippfehler nicht
|
||||
still zum Standarddesign führt.
|
||||
|
||||
## Dunkelmodus
|
||||
|
||||
Umschalter rechts oben im Kopf; die Wahl liegt im `localStorage`
|
||||
(`icon_crm_theme_mode`) **und** am Benutzer in der Datenbank, gilt also auf jedem
|
||||
Gerät. Ohne Wahl folgt die Anwendung der Systemeinstellung, ein Doppelklick auf
|
||||
den Umschalter stellt das wieder her.
|
||||
|
||||
Umgesetzt ist der Modus **ohne `dark:`-Varianten im Markup**: die Klasse `dark`
|
||||
auf `<html>` tauscht in `src/index.css` nur die Werte von `canvas`, `surface`,
|
||||
`ink-*`, `brand-*` und der getönten Statusfarben. Ein Inline-Skript in
|
||||
`index.html` setzt die Klasse vor dem ersten Frame, sonst blitzt beim Laden die
|
||||
helle Variante auf.
|
||||
|
||||
## Menü
|
||||
|
||||
```
|
||||
Dashboard
|
||||
Leads Pipeline als Kanban
|
||||
└ Archiviert alles, was per Aktenschrank weggelegt wurde
|
||||
Kontakte
|
||||
Unternehmen
|
||||
Ausschreibungen Tabelle; nur der laufende Bestand
|
||||
├ Live! Kanban über die Phasen
|
||||
└ Archiv archivierte Ausschreibungen, von dort zurückholbar
|
||||
Laufende Projekte
|
||||
Historie
|
||||
Einstellungen nur für Administratoren
|
||||
```
|
||||
|
||||
Die Unterpunkte klappen auf, sobald ein Punkt des Zweigs benutzt wird.
|
||||
Dauerhaft ausgeklappt wäre die Leiste doppelt so lang, und Archive schaut man
|
||||
selten an.
|
||||
|
||||
## Stammdaten
|
||||
|
||||
*Einstellungen → Stammdaten* (nur Administratoren) pflegt die Auswahllisten, die
|
||||
die Formulare speisen: **Branchen** und **Arten der Kontakte**. Beides war vorher
|
||||
Freitext bzw. fest im Quelltext; beides wächst im Betrieb.
|
||||
|
||||
Zwei Eigenschaften, die in der Oberfläche stehen, weil man sie beim Pflegen
|
||||
kennen muss:
|
||||
|
||||
* **Umbenennen zieht den Bestand mit.** Sonst trüge ein Unternehmen eine
|
||||
Branche, die seine Auswahlliste nicht mehr kennt, und das Feld stünde beim
|
||||
nächsten Öffnen leer.
|
||||
* **Entfernen geht nur, solange ein Wert nirgends benutzt wird.** Hinter jedem
|
||||
Eintrag steht deshalb, wie oft er vorkommt.
|
||||
|
||||
Ein Altwert, der nicht mehr in der Liste steht, bleibt im Formular trotzdem
|
||||
wählbar – sonst würde er beim nächsten Speichern still verschwinden.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Browser ──► Apache (:443/:80, icrm.icon-experts.de)
|
||||
├── / → /var/www/icon_CRM/dist (SPA, FallbackResource)
|
||||
└── /api → 127.0.0.1:5007 (Express)
|
||||
└──► MariaDB icon_CRM
|
||||
```
|
||||
|
||||
Der Browser spricht **nie** direkt mit der Datenbank – deshalb das
|
||||
Express-Backend. Es bindet ausschließlich auf `127.0.0.1` und ist von außen nur
|
||||
über den Apache-Proxy erreichbar. Das ersetzt den früheren Aufbau, bei dem der
|
||||
Supabase-Anon-Key im Browser lag und vollen Zugriff auf `icon_users` samt
|
||||
Passwort-Hashes hatte.
|
||||
|
||||
### Datenmodell
|
||||
|
||||
| Tabelle | Inhalt |
|
||||
| --- | --- |
|
||||
| `icon_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, Farbe, Foto, Design |
|
||||
| `icon_companies` | Unternehmen: Gruppe, Branche, Mitarbeiterzahl, 5-Sterne-Bewertung, `is_customer` + `customer_state` (aktiv/alumni), Freelancer-Einsatz |
|
||||
| `icon_contacts` | Kontakte – Experten **und** Ansprechpartner, unterschieden über `kind`; inkl. Lead-Quelle und Kontaktstand |
|
||||
| `icon_employments` | Werdegang: wer war wann bei welchem Unternehmen – speist den Zweig „Verlassen" |
|
||||
| `icon_interviews` | Wann wurde mit wem gesprochen |
|
||||
| `icon_history` | Alle Tätigkeiten: Zeitstempel, Benutzer, Text, Feldänderungen |
|
||||
| `icon_mentions` | Index der `@`-Erwähnungen (Quelle → Ziel) |
|
||||
| `icon_connections` | Wer kennt wen; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
|
||||
| `icon_tenders`, `icon_tender_contacts` | Ausschreibungen und die zugeordneten Kontakte |
|
||||
| `icon_projects` | Laufende Projekte |
|
||||
| `icon_leads`, `icon_lead_todos` | Lead-Pipeline mit Checkliste und Erinnerungen |
|
||||
| `icon_cv_imports` | CV-Import: Eingang, Screening, Auswahl |
|
||||
| `icon_options` | Gepflegte Auswahllisten (Branchen, Arten der Kontakte) |
|
||||
| `icon_settings` | Schlüssel/Wert, auch für Migrationsmerker |
|
||||
|
||||
Zwei Entscheidungen stecken im Modell:
|
||||
|
||||
* **Ein Kunde ist kein eigener Datentyp**, sondern ein Unternehmen mit
|
||||
`is_customer = 1`. Der Umschalter in der Oberfläche filtert nur darauf – es
|
||||
gibt keine zweite Liste, die auseinanderlaufen könnte.
|
||||
* **Experten und Ansprechpartner liegen in einer Tabelle.** Ein Ansprechpartner,
|
||||
der die Firma wechselt und plötzlich als Experte interessant wird, ist
|
||||
derselbe Mensch und behält seine Historie.
|
||||
|
||||
### 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
|
||||
Erwähnungen, Zuständigkeiten und die Historie. Passwort-Hashes liefert der
|
||||
Endpunkt nie.
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx Layout, gemeinsame Daten, Ansichtswechsel
|
||||
types.ts Gemeinsame Typen und Konstanten
|
||||
lib/api.ts Fetch-Wrapper + typisierte Endpunkte
|
||||
lib/auth.tsx Auth-Context (JWT im localStorage)
|
||||
lib/theme.tsx Modus und Design
|
||||
lib/navigation.tsx Karte öffnen aus beliebiger Tiefe
|
||||
lib/mentions.tsx @-Token: Parser, Renderer, Autocomplete
|
||||
lib/imageCompress.ts Bilder im Browser auf 400 px verkleinern
|
||||
components/
|
||||
Sidebar.tsx, Header.tsx, GlobalSearch.tsx, Toast.tsx, LoginScreen.tsx
|
||||
ui/Modal.tsx Dialog + Bestätigung; Hintergrundklick schließt
|
||||
ui/Avatar.tsx Foto, sonst Initialen; quadratisch für Logos
|
||||
ui/AvatarPicker.tsx Upload, LinkedIn-Abruf, Entfernen
|
||||
ui/StarRating.tsx 5-Sterne-Bewertung der Unternehmen
|
||||
ui/form.tsx Eingabestile, Knöpfe, Pillen, Umschalter
|
||||
settings/MasterData.tsx Stammdaten: die gepflegten Auswahllisten
|
||||
contacts/ Liste, Kacheln, Netzwerk, Karte mit Werdegang
|
||||
companies/ Liste, Kacheln, Group, Netzwerk
|
||||
network/Network.tsx Die Netzwerkansicht beider Listen (d3-force + SVG)
|
||||
leads/ Pipeline als Kanban, Archiv, Erinnerungen
|
||||
tenders/Tenders.tsx Tabelle mit aufklappbarer Zeile (wie im Original)
|
||||
tenders/TenderText.tsx Ausschreibungstext (WYSIWYG + KI) auf `notes`
|
||||
tenders/LiveBoard.tsx Ansicht „Live!" – Kanban über die Phasen
|
||||
projects/ Laufende Projekte
|
||||
history/ Reiter Historie und der Verlauf je Karte
|
||||
dashboard/, settings/
|
||||
server/
|
||||
src/server.js Express-App
|
||||
src/db.js Pool, Schema-Init, Admin-Seed
|
||||
src/schema.sql Idempotentes Schema
|
||||
src/history.js Historie schreiben, Erwähnungen indizieren
|
||||
src/migrate.js Einmalige Übernahme aus Supabase
|
||||
src/linkedinPhoto.js Profilfoto aus der Open-Graph-Vorschau
|
||||
src/middleware/auth.js JWT-Prüfung, Admin-Wächter
|
||||
src/routes/ auth, users, companies, contacts, tenders,
|
||||
projects, history, search, network, connections,
|
||||
dashboard
|
||||
deploy/
|
||||
icon-crm-backend.service systemd-Unit
|
||||
icrm.icon-experts.de.conf Apache-Vhost
|
||||
deploy.ps1 Build lokal, Upload, Neustart
|
||||
legacy/ Der alte Supabase-Stand, nur zum Nachschlagen
|
||||
```
|
||||
|
||||
## Kleinigkeiten mit Absicht
|
||||
|
||||
* **„Zuständig" ist beim Anlegen mit dem angemeldeten Benutzer vorbelegt** – in
|
||||
aller Regel kümmert sich der, der den Datensatz erfasst. Ändern geht weiter.
|
||||
* **Erwähnungen sind anklickbar.** Im Eingabefeld steht der rohe Token
|
||||
`@[Name](contact:12)` – anders ließe sich der Text nicht bearbeiten. Darunter
|
||||
steht deshalb die Lesefassung, und dort führt jede Erwähnung auf das Profil.
|
||||
|
||||
## Historie und Erwähnungen
|
||||
|
||||
Jede schreibende Route ruft `logHistory()` auf, statt selbst in `icon_history` zu
|
||||
schreiben. Dadurch gilt an einer Stelle, was überall gelten soll: **in jeder
|
||||
Historie steht, welcher Benutzer was geändert hat.** Bei einer Änderung werden
|
||||
zusätzlich die betroffenen Felder mit Vorher/Nachher abgelegt.
|
||||
|
||||
Erwähnungen stehen als Token im Text: `@[Anzeigename](contact:12)`, ebenso für
|
||||
`company` und `user`. Der Name steht mit im Token, damit ein Text auch ohne
|
||||
geladene Listen lesbar bleibt; beim Rendern gewinnt der aktuelle Name. Jede
|
||||
Erwähnung wird zusätzlich in `icon_mentions` indiziert – deshalb kann eine Karte
|
||||
zeigen, wo sie überall genannt wurde, ohne jeden Freitext der Datenbank zu
|
||||
durchsuchen.
|
||||
|
||||
Ein Arbeitgeberwechsel erzeugt drei Einträge von selbst: beim alten Unternehmen
|
||||
„hat das Unternehmen verlassen", beim neuen „ist jetzt hier tätig", beim Kontakt
|
||||
den Wechsel. Daraus speist sich der Zweig **Verlassen** im Stammbaum.
|
||||
|
||||
## Globale Suche
|
||||
|
||||
Ein Feld, zwei Modi – umgeschaltet über die beiden Symbole rechts im Feld:
|
||||
|
||||
**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen,
|
||||
Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein
|
||||
Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die
|
||||
Datenmengen sind klein, und ein `FULLTEXT`-Index würde bei Teilwortsuche –
|
||||
„informat" soll „Finanz Informatik" finden – nicht helfen.
|
||||
|
||||
**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet von
|
||||
`gpt-5.6-luna`: *„Wer arbeitet alles bei der KfW?"* → „Bei der KfW ist Gunther
|
||||
Stammwitz als Ansprechpartner hinterlegt." Die genannten Datensätze kommen als
|
||||
anklickbare Chips zurück und öffnen ihre Karte.
|
||||
|
||||
Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage
|
||||
kostet. Und sie erzeugt **kein SQL**: der Bestand wird zusammengefasst und
|
||||
mitgegeben (`server/src/routes/search.js`). Ein Modell, das Abfragen auf die
|
||||
Datenbank schreiben darf, wäre eine Angriffsfläche; der Bestand ist klein genug,
|
||||
um ihn vollständig mitzuliefern. Ab 400 Zeilen je Liste wird gekürzt, und die
|
||||
Antwort sagt das dazu.
|
||||
|
||||
## KI
|
||||
|
||||
Modell: **`gpt-5.6-luna`** (OpenAI), angesprochen über `chat/completions`.
|
||||
Schlüssel und Modell stehen ausschließlich in `server/.env` (chmod 600) – der
|
||||
Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend
|
||||
(`server/src/ai.js`). Fehlt der Schlüssel, läuft das CRM normal weiter und
|
||||
blendet die KI-Knöpfe aus.
|
||||
|
||||
> **Fallstrick:** `gpt-5.6-luna` akzeptiert kein eigenes `temperature` und weist
|
||||
> jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb keines.
|
||||
|
||||
### CV-Import
|
||||
|
||||
*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl
|
||||
oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB). Der Ablauf folgt der
|
||||
Featureliste als drei Ordner:
|
||||
|
||||
| Ordner | Bedeutung |
|
||||
| --- | --- |
|
||||
| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen“ oder „Nein“ |
|
||||
| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt |
|
||||
| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran |
|
||||
|
||||
Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder
|
||||
Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort
|
||||
korrigiert wird, gewinnt gegen die KI.
|
||||
|
||||
Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber,
|
||||
Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen
|
||||
– die Stundensätze. Frühere Stationen werden als **beendete** Anstellungen
|
||||
angelegt; dadurch erscheint der Kontakt beim jeweiligen Unternehmen sofort im
|
||||
Zweig „Verlassen“. Unbekannte Unternehmen legt der Import mit an.
|
||||
|
||||
Die Datei bleibt am Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen
|
||||
Karte herunterladen. Der Download läuft über `fetch` mit Bearer-Kopf – ein
|
||||
normaler Link schickt ihn nicht mit.
|
||||
|
||||
### Schreibhilfe für Ausschreibungen
|
||||
|
||||
Das Feld *Ausschreibungstext* (also `icon_tenders.notes`, siehe unten) ist ein
|
||||
WYSIWYG-Editor (`components/ui/RichTextEditor.tsx`) mit fünf Knöpfen: *Text erzeugen*,
|
||||
*Verbessern*, *Kürzen*, *Stichpunkte*, *Anforderungsprofil*. Darunter ein Feld
|
||||
für die Vorgabe („Interim PL SAP S/4HANA, Bank, 12 Monate, 80 % remote“). Der
|
||||
vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt nichts
|
||||
unwiderruflich.
|
||||
|
||||
Der Editor kommt ohne Fremdpaket aus: Quill & Co. bringen ihr eigenes
|
||||
Farbschema mit, das im Dunkelmodus gegen die Variablen dieser Anwendung
|
||||
arbeitet, und kosten über 200 kB. Gebraucht werden sechs Formate – das leistet
|
||||
`contentEditable`. Eingefügter Text wird bewusst entformatiert: aus Word oder
|
||||
einem Portal kämen sonst fremde Schriftgrößen und `<span style>`-Wolken mit.
|
||||
|
||||
### Erweiterte Suche und die Sätze
|
||||
|
||||
Die Frage „wer hat den höchsten Satz?" ging lange ins Leere: der Bestand, den
|
||||
die erweiterte Suche mitbekommt, enthielt die Felder `rate_onsite` und
|
||||
`rate_remote` gar nicht. Sie sind jetzt dabei, zusammen mit Kontaktstand und
|
||||
LinkedIn.
|
||||
|
||||
Zwei Dinge stehen dafür ausdrücklich im Systemprompt, weil das Modell sie sonst
|
||||
falsch beantwortet:
|
||||
|
||||
* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert
|
||||
steht, und die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist.
|
||||
* Es sind **Stundensätze**. Fragt jemand nach einem Tagessatz, antwortet die
|
||||
Suche mit dem Stundensatz und sagt das dazu.
|
||||
|
||||
> **Zur Benennung:** Die Felder heißen `rate_onsite`/`rate_remote` und stammen
|
||||
> aus `hourlyRateOnsite`/`hourlyRateRemote` des alten CRM, das sie als
|
||||
> „Stundensatz Onsite (€)" beschriftet und als `€/h` angezeigt hat. Das neue
|
||||
> CRM hatte sie zwischenzeitlich „Tagessatz" genannt – bei Werten bis 165 €
|
||||
> wäre das ein sehr günstiger Tag gewesen. Beschriftung und Anzeige tragen die
|
||||
> Einheit jetzt überall mit.
|
||||
|
||||
## Diktat (Whisper im Browser)
|
||||
|
||||
Überall dort, wo längerer Text entsteht – Notiz am Kontakt, Notizen an Lead,
|
||||
Unternehmen und Projekt, Ausschreibungstext – sitzt ein Mikrofon-Knopf. Die
|
||||
Erkennung läuft **vollständig im Browser**: das Backend sieht keine Audiodaten,
|
||||
es gibt keinen Schlüssel und keine Kosten pro Minute. Aufnahmen werden nirgends
|
||||
gespeichert.
|
||||
|
||||
Übernommen aus dem perinco CRM, 1:1 in der Technik:
|
||||
|
||||
| Teil | Datei |
|
||||
| --- | --- |
|
||||
| Modellverwaltung, Zustand, Cache | `src/lib/whisper.tsx` |
|
||||
| Erkennung im Web Worker | `src/lib/whisper.worker.ts` |
|
||||
| Aufnahme (MediaRecorder) | `src/lib/dictation.tsx` |
|
||||
| Umrechnung auf 16 kHz Mono | `src/lib/audio.ts` |
|
||||
| Downloadgröße bei Hugging Face | `src/lib/whisperModels.ts` |
|
||||
| Knopf und Textfeld | `src/components/ui/Dictate.tsx` |
|
||||
| Reiter *Einstellungen → Diktat* | `src/components/settings/WhisperSettings.tsx` |
|
||||
|
||||
Festgelegt und nicht einstellbar: `onnx-community/whisper-large-v3-turbo`, `q4`,
|
||||
Sprache Deutsch, Grafikkarte wenn vorhanden (WebGPU), sonst CPU (WASM). Die
|
||||
Begründung dazu steht im Kopf von `src/lib/whisper.tsx` – kurz: kleinere Modelle
|
||||
verlieren genau bei den Fachbegriffen, andere Genauigkeiten scheitern am
|
||||
Speicher oder liefern auf der Grafikkarte Unsinn.
|
||||
|
||||
Das Modell wird **einmal je Browser** geladen (rund 300 MB) und liegt danach im
|
||||
Cache-Speicher der Seite; die ONNX-Laufzeit liefert das Build selbst aus
|
||||
(`dist/assets/ort-wasm-simd-threaded.jsep-*.wasm`, 21 MB). Ist es im Cache,
|
||||
stellt sich das Diktat beim Anmelden von selbst bereit – ein Download von 300 MB
|
||||
startet nie unaufgefordert. *Einstellungen → Diktat* zeigt den Zustand, eine
|
||||
Probeaufnahme und den Weg, das Modell wieder loszuwerden.
|
||||
|
||||
Erkannter Text wird **angehängt**, nie ersetzt: in den WYSIWYG-Feldern als
|
||||
eigener Absatz am Ende, in den Textfeldern hinter dem vorhandenen Text. Ohne
|
||||
Mikrofon oder ohne HTTPS bleibt der Knopf unsichtbar, ohne geladenes Modell
|
||||
steht dort *Diktat einrichten* und führt direkt in den Reiter.
|
||||
|
||||
## Ausschreibungen und die ICON Website
|
||||
|
||||
Die Ansicht ist bewusst so aufgebaut wie im ursprünglichen ICON CRM: eine
|
||||
Tabelle, deren Zeile sich aufklappt – links die zugewiesenen Experten und der
|
||||
Kundenkontakt, rechts der Ausschreibungstext.
|
||||
|
||||
Zwei Dinge sind dabei keine Kosmetik, sondern hängen an der Website:
|
||||
|
||||
* **Der Ausschreibungstext steht in `icon_tenders.notes`**, als HTML. Nicht in
|
||||
`details` – dieses Feld ist im gesamten Bestand leer und bleibt ungenutzt. Wer
|
||||
den Editor auf `details` legt, zeigt ein leeres Feld und schreibt am
|
||||
veröffentlichten Text vorbei.
|
||||
* **`online` entscheidet, ob eine Ausschreibung auf der Website erscheint.** Der
|
||||
Schalter sitzt deshalb direkt in der Liste, einen Klick entfernt.
|
||||
|
||||
Gespeichert wird der Text automatisch, 800 ms nach dem letzten Tastendruck –
|
||||
wie zuvor auch, es gibt keinen Speichern-Knopf.
|
||||
|
||||
Das Kanban über die Phasen ist nicht Teil dieser Ansicht, sondern steht als
|
||||
**Live!** im Menü. Anders als früher ist es bearbeitbar: eine Kachel lässt sich
|
||||
in eine andere Spalte ziehen, und der Wechsel landet in der Historie.
|
||||
|
||||
### Die ICON Website
|
||||
|
||||
Seit dem 14.09.2026 liegt die Website **auf demselben Server** wie das CRM
|
||||
(`/var/www/icon_website/dist`, Vhost `icon-experts.de` + `www`). Vorher lief sie
|
||||
auf `212.227.135.41` – derselben Maschine wie perinco CRM, n8n, Docker, Supabase
|
||||
und ein halbes Dutzend weiterer Dienste, bei 5,5 von 7,9 GB belegtem RAM.
|
||||
|
||||
Der Vhost reicht **`/api/public` an das CRM-Backend durch**. Das ist kein
|
||||
Schönheitsfehler, sondern der Grund, warum die Content-Security-Policy der Seite
|
||||
weiter mit `connect-src 'self'` auskommt: der Aufruf ist gleicher Herkunft.
|
||||
Zeigte die Seite direkt auf `icrm.icon-experts.de`, müsste dieser Host in der CSP
|
||||
stehen und der Browser bräuchte einen CORS-Vorabruf. Durchgereicht wird
|
||||
ausschließlich `/api/public` – Anmeldung, Kontakte und Leads bleiben von der
|
||||
Website aus unerreichbar.
|
||||
|
||||
### Öffentliche Schnittstelle für die Website
|
||||
|
||||
`GET /api/public/tenders` – ohne Anmeldung, nur Lesen, nur Zeilen mit
|
||||
`online = 1`, neueste zuerst. Dazu `GET /api/public/tenders/:id` für einen
|
||||
direkten Link. Beide antworten mit `Access-Control-Allow-Origin: *` und
|
||||
`Cache-Control: public, max-age=60`.
|
||||
|
||||
Ausgeliefert werden genau die Felder, die die Projektbörse anzeigt:
|
||||
|
||||
```json
|
||||
[{ "id": 8, "title": "…", "status": "Aktiv", "notes": "<h2>…</h2>",
|
||||
"created_at": "…", "updated_at": "…" }]
|
||||
```
|
||||
|
||||
**Nicht** ausgeliefert werden der zugeordnete Kunde, die zugewiesenen Experten,
|
||||
die Quelle, Fristen und die Phase. Das ist der eigentliche Gewinn gegenüber dem
|
||||
vorherigen Weg: die Website las mit dem Supabase-Anon-Key direkt in der Tabelle
|
||||
und durfte `select('*')` – der Schlüssel lag dafür im Browser-Bundle.
|
||||
|
||||
Auf Seiten der Website (`D:\Vibe Coding\ICON Website`) hängt das an
|
||||
`src/lib/iconCrm.js`; `src/pages/Experts.jsx` ruft nur noch `fetchTenders()`
|
||||
auf. Die Adresse lässt sich über `VITE_ICON_CRM_URL` umbiegen, Vorgabe ist
|
||||
`https://icrm.icon-experts.de`. `@supabase/supabase-js` und `src/lib/supabase.js`
|
||||
werden dort seither nirgends mehr benutzt und könnten entfallen.
|
||||
|
||||
> **Zwei Bedingungen, nicht eine:** `online = 1` **und** nicht archiviert. Im
|
||||
> Altbestand steht eine archivierte Ausschreibung, deren Schalter noch an ist –
|
||||
> über Supabase war sie unsichtbar, über diesen Endpunkt wäre sie mit der
|
||||
> Kategorie „Archiviert" auf der Website aufgetaucht.
|
||||
|
||||
## Kontakte aus dem perinco CRM
|
||||
|
||||
*Kontakte → Schalter „perinco".* Die Kontakte des perinco CRM werden zusätzlich
|
||||
angezeigt – **grün umrandet**, rein lesend, und **nichts davon wird
|
||||
gespeichert**. Ein nächtlicher Abgleich in die eigene Datenbank wäre der falsche
|
||||
Weg: dann stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu
|
||||
klären, welcher Eintrag gilt.
|
||||
|
||||
Das gilt **in beide Richtungen**, gleich gebaut:
|
||||
|
||||
```
|
||||
ICON ──HTTPS + Token──► pcrm.perinco.de/api/integrations/contacts ──► crm_leads
|
||||
perinco ──HTTPS + Token──► icrm.icon-experts.de/api/integrations/contacts ──► icon_contacts
|
||||
```
|
||||
|
||||
Im ICON CRM erscheinen die perinco-Kontakte **grün** umrandet (Häkchen
|
||||
„perinco" in den Kontakten), im perinco CRM die ICON-Kontakte **gold**
|
||||
(Häkchen „ICON"). Beide Seiten speichern nichts, beide kennen nur `GET`.
|
||||
|
||||
Auf perinco-Seite (`server/src/routes/integrations.js`) hängt ein eigener
|
||||
Router mit festem Token statt JWT – der Aufrufer ist das ICON-Backend, kein
|
||||
angemeldeter Mensch. Nur `GET`, kein Schreibweg, auch später nicht. Der Token
|
||||
steht dort als `ICON_API_TOKEN`, hier als `PERINCO_API_TOKEN`.
|
||||
|
||||
**Wer weitergegeben wird, entscheidet ein Häkchen im perinco CRM.** Dort trägt
|
||||
jeder Kontakt das Feld *„Im ICON CRM anzeigen“* (`crm_leads.icon`); nur wer es
|
||||
gesetzt hat, taucht hier auf. Die Weitergabe ist damit eine Entscheidung am
|
||||
einzelnen Datensatz und nicht die Folge davon, dass ein Kontakt existiert.
|
||||
|
||||
Beim Anlegen der Spalte wurden alle vorhandenen Kontakte auf gesetzt gestellt –
|
||||
sie waren bis dahin hier sichtbar, und ein Feld einzuführen darf nichts
|
||||
verschwinden lassen. **Neue Kontakte starten ungesetzt.**
|
||||
|
||||
Abgebildet wird auf die **vorhandenen** Felder eines ICON-Kontakts; neue Felder
|
||||
wurden ausdrücklich keine angelegt:
|
||||
|
||||
| perinco `crm_leads` | ICON |
|
||||
| --- | --- |
|
||||
| `first_name`, `last_name`, `position` | gleichnamig |
|
||||
| `company` | wird als Unternehmensname angezeigt |
|
||||
| `email`, `phone` bzw. `mobile` | `email`, `phone` |
|
||||
| `linkedin`, `avatar`, `tags` | gleichnamig |
|
||||
| `kind` (Recruiter/Partner/Endkunde) | `kind` – **unverändert**, nicht auf die ICON-Arten umgebogen |
|
||||
| `source` | `lead_source` |
|
||||
| `notes` | `bio` |
|
||||
|
||||
Bleiben drüben, weil ICON bei Kontakten kein Gegenstück hat: Anrede, Website,
|
||||
Anschrift. Ebenso Zuständiger und Ersteller – das sind Benutzer *jenes* Systems,
|
||||
deren Kennungen hier nichts bedeuten. Kontakte im Vorbereich „Aus Mails"
|
||||
(`mail_pool = 1`) bleiben ebenfalls außen vor: unvollständig und teils Dubletten.
|
||||
|
||||
Die Kennung bekommt ein Präfix (`perinco-31`), damit sie nie mit einer
|
||||
ICON-Kennung verwechselt wird. Ohne eingerichtete Schnittstelle bleibt der
|
||||
Schalter unsichtbar – ein Knopf, der nur eine Fehlermeldung erzeugt, ist keiner.
|
||||
|
||||
In der **Netzwerkansicht** erscheinen sie mit, solange der Schalter an ist:
|
||||
grün umrandete Kreise, über eine fein gestrichelte grüne Kante an ihr
|
||||
Unternehmen gehängt. Die Verbindung läuft über den **Firmennamen**, denn drüben
|
||||
steht dort nur Text und keine Kennung – groß/klein wird ignoriert, damit „KfW“
|
||||
und „KFW“ zusammenfinden. Passt kein Name, steht der Kontakt für sich; das ist
|
||||
ehrlicher als eine geratene Zuordnung. Verbinden lassen sie sich nicht, und ihre
|
||||
Seitenleiste bietet keine Karte an – gepflegt werden sie drüben.
|
||||
|
||||
## Lead-Pipeline
|
||||
|
||||
Kanban über acht Phasen (*Backlog* bis *Absage*) mit Drag & Drop. Eine Kachel
|
||||
trägt die Daten des Unternehmens und des Ansprechpartners, den Kontaktstand, den
|
||||
Stand der Checkliste und die nächste Erinnerung. Auf jeder Kachel sitzen zwei kleine Knöpfe: ein
|
||||
**Aktenschrank** (archivieren) und ein roter **Mülleimer** (löschen, mit
|
||||
Rückfrage). Archiviertes steht im Menü unter *Leads → Archiviert* und lässt sich
|
||||
von dort zurückholen.
|
||||
|
||||
Die Karte eines Leads hat drei Reiter: Stammdaten (inkl. Erinnerung), **ToDos**
|
||||
als Checkliste mit Fälligkeitsdatum und Fortschrittsbalken in Prozent, und die
|
||||
Historie.
|
||||
|
||||
**Erinnerungen** gibt es in zwei Formen, beide als Pop-up, das sich unabhängig
|
||||
von der offenen Ansicht meldet (`components/leads/ReminderWatcher.tsx`, Abfrage
|
||||
beim Anmelden und danach stündlich):
|
||||
|
||||
* **gesetzt** – „in 2 Tagen anrufen“. Eingegeben werden Tage, nicht ein Datum;
|
||||
gerechnet wird auf dem Server, sonst hinge das Ergebnis an der Zeitzone des
|
||||
Browsers. Quittieren heißt *Erledigt* oder *in n Tagen erneut*.
|
||||
* **automatisch** – wird ein Lead 30 Tage nicht angefasst, fragt das CRM, ob er
|
||||
archiviert werden soll. Wird er das nicht, läuft die Frist von vorn: das
|
||||
Quittieren setzt `last_contact_at` auf jetzt. Jede Bearbeitung des Leads gilt
|
||||
ebenfalls als Berührung – nur das Umsortieren innerhalb einer Spalte nicht.
|
||||
|
||||
## Netzwerkansicht
|
||||
|
||||
Sie ist **kein eigener Menüpunkt**, sondern die Netzwerk-Ansicht der Kontakte
|
||||
und der Unternehmen – wie im perinco CRM. Beide Listen reichen ihre Filter
|
||||
durch, es sind also dieselben Daten, nur anders gezeichnet. In den Kontakten
|
||||
lassen sich zusätzlich Verbindungen anlegen: *Verbindung erstellen*, dann zwei
|
||||
Kontakte anklicken; Esc bricht ab.
|
||||
|
||||
Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
|
||||
|
||||
* **Rechtecke** = Unternehmen, Größe nach Anzahl Verbindungen, dunkler Rahmen bei Kunden
|
||||
* **Kreise** = Kontakte
|
||||
* **Goldene Linie** = arbeitet dort · **graue gestrichelte Linie** = hat dort gearbeitet
|
||||
* **Feine Linie** = Kontakt kennt Kontakt
|
||||
|
||||
Hintergrund ziehen verschiebt, Mausrad zoomt auf den Zeiger, Knoten ziehen
|
||||
fixiert ihn (goldener Punkt), Doppelklick löst die Fixierung, Klick auf freie
|
||||
Fläche hebt die Auswahl auf. Damit das Verschieben nicht versehentlich die
|
||||
Auswahl verwirft, gilt eine Geste erst ab 4 px Weg als Ziehen.
|
||||
|
||||
### Stammbaum eines Unternehmens
|
||||
|
||||
Ein Klick auf ein Unternehmen, an dem Kontakte hängen, öffnet dessen Stammbaum
|
||||
(`components/network/CompanyTree.tsx`): das Unternehmen oben, die Kontakte
|
||||
darunter, verbunden durch Äste. Die Farbe des Astes sagt, welcher Art die
|
||||
Verbindung ist:
|
||||
|
||||
| Ast | Bedeutung |
|
||||
| --- | --- |
|
||||
| **golden** | Berater – Kontaktart „Experte" |
|
||||
| **hell** | normale Verbindung – Ansprechpartner und übrige Kontakte |
|
||||
| **grau, gestrichelt** | Ehemalige – beendete Anstellung; darunter steht, wo die Person heute ist |
|
||||
|
||||
Die Art des Astes ergibt sich aus der **Beziehung**, nicht aus der Person: wer
|
||||
das Unternehmen verlassen hat, hängt am grauen Ast – auch wenn er Berater ist.
|
||||
Erst unter den heutigen wird zwischen Berater und übrigen unterschieden. Wer
|
||||
wieder hier arbeitet, steht nicht zusätzlich unter „Ehemalige".
|
||||
|
||||
Der graue Ast ist zusätzlich **gestrichelt**, und die normale Verbindung nimmt
|
||||
`ink-700` statt reinem Weiß: im hellen Modus wäre ein weißer Ast auf weißem
|
||||
Grund unsichtbar, und ohne die Strichelung lägen Grau und Neutral dort zu nah
|
||||
beieinander. Im Dunkelmodus ist `ink-700` nahezu weiß – dort sieht es aus wie
|
||||
gedacht.
|
||||
|
||||
Passen nicht alle Kontakte nebeneinander, bricht die Reihe um; jede Zeile wird
|
||||
für sich mittig ausgerichtet. Ein Klick auf einen Kontakt öffnet dessen Karte,
|
||||
*Zurück zum Netz* führt in die Kräfteansicht zurück.
|
||||
|
||||
## Fotos
|
||||
|
||||
Fotos und Logos liegen als Data-URL in der Datenbank. Der Browser verkleinert
|
||||
jedes Bild **vor** dem Upload (`src/lib/imageCompress.ts`): mittiger Ausschnitt
|
||||
auf ein Quadrat von 400 px, WebP mit Qualität 0,82, Rückfall JPEG, dann PNG. Das
|
||||
Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
|
||||
Länge.
|
||||
|
||||
Trägt man beim Kontakt eine LinkedIn-Adresse ein, holt der Knopf *Von LinkedIn*
|
||||
das öffentliche Profilfoto. Den Abruf macht das Backend
|
||||
(`server/src/linkedinPhoto.js`) – aus dem Browser heraus stünde die
|
||||
Same-Origin-Policy davor. Gelesen wird **ausschließlich** die
|
||||
Open-Graph-Vorschau der öffentlichen Profilseite; der übrige Quelltext wird nicht
|
||||
durchsucht, weil dort auch Firmenlogos und fremde Profilbilder stehen.
|
||||
|
||||
## Migration aus dem alten CRM
|
||||
|
||||
`server/src/migrate.js` übernimmt den Supabase-Bestand einmalig.
|
||||
|
||||
```bash
|
||||
cd /var/www/icon_CRM/server
|
||||
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js
|
||||
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js --write
|
||||
```
|
||||
|
||||
Ohne `--write` ist es ein Probelauf: er liest, rechnet die Zuordnung aus und
|
||||
zeigt, was passieren würde. Wiederholbar – Unternehmen und Kontakte werden über
|
||||
ihren Namen wiedererkannt.
|
||||
|
||||
Abbildung der Altdaten:
|
||||
|
||||
| alt | neu |
|
||||
| --- | --- |
|
||||
| `icon_customers` | `icon_companies` mit `is_customer = 1` |
|
||||
| `icon_contacts` | `icon_contacts`, `kind = 'Ansprechpartner'` |
|
||||
| `icon_experts` | `icon_contacts`, `kind = 'Experte'` |
|
||||
| Status `Verfügbar` | `Interessiert` – der Verfügbarkeitsstatus ist entfallen |
|
||||
| `kanbanStatus: Experten sichten` | Phase `Kontakte sichten` |
|
||||
|
||||
**Passwörter:** im alten CRM lagen sie als ungesalzenes SHA-256 in Supabase. Die
|
||||
Hashes werden übernommen und sind an ihrer Form erkennbar (64 Hexziffern). Beim
|
||||
ersten erfolgreichen Login schreibt das Backend sie auf bcrypt um – danach gilt
|
||||
nur noch der starke Hash. Neu vergeben wird ausschließlich bcrypt.
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd server && npm install && cp .env.example .env # Werte anpassen
|
||||
npm start # Backend :5007
|
||||
cd .. && npm run dev # Frontend :3101, /api wird geproxied
|
||||
```
|
||||
|
||||
Lokal braucht es eine erreichbare MariaDB mit der Datenbank `icon_CRM`; das
|
||||
Schema legt das Backend beim Start selbst an.
|
||||
|
||||
## Deployment
|
||||
|
||||
Gebaut wird **lokal**, auf den Server gehen nur `dist/` plus Backend-Quellen.
|
||||
|
||||
```powershell
|
||||
$env:ICON_CRM_SSH_PASSWORD = '…'
|
||||
powershell -File deploy/deploy.ps1
|
||||
powershell -File deploy/deploy.ps1 -Deps # bei neuen Backend-Paketen
|
||||
```
|
||||
|
||||
### Serverseitige Einrichtung (einmalig, bereits erledigt)
|
||||
|
||||
```bash
|
||||
apt-get install -y apache2 mariadb-server certbot python3-certbot-apache nodejs
|
||||
a2enmod proxy proxy_http rewrite headers expires
|
||||
mariadb -e "CREATE DATABASE icon_CRM CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
mariadb -e "CREATE USER 'iconcrm'@'localhost' IDENTIFIED BY '…';
|
||||
GRANT ALL PRIVILEGES ON icon_CRM.* TO 'iconcrm'@'localhost';"
|
||||
cd /var/www/icon_CRM/server && npm install --omit=dev
|
||||
cp deploy/icon-crm-backend.service /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable --now icon-crm-backend
|
||||
cp deploy/icrm.icon-experts.de.conf /etc/apache2/sites-available/
|
||||
a2ensite icrm.icon-experts.de && systemctl reload apache2
|
||||
certbot --apache -d icrm.icon-experts.de
|
||||
```
|
||||
|
||||
Die Firewall (`ufw`) lässt 22, 80 und 443 durch. Das Zertifikat erneuert ein
|
||||
Zeitplan von certbot selbst; es ist ohne Kontaktadresse ausgestellt, es kommen
|
||||
also keine Ablaufwarnungen per Mail.
|
||||
|
||||
## Zugang
|
||||
|
||||
Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in
|
||||
`server/.env` angelegt – aber nur, solange `icon_users` leer ist. Danach ist das
|
||||
Passwort ausschließlich über die Nutzerverwaltung bzw. *Mein Konto* änderbar.
|
||||
|
||||
## Was noch offen ist
|
||||
|
||||
Aus der Featureliste vom 14.09.2026 stehen noch aus – jeweils mit dem, was dafür
|
||||
gebraucht wird:
|
||||
|
||||
| Offen | Was fehlt dafür |
|
||||
| --- | --- |
|
||||
| Scraper für Stellenausschreibungen | Quellen von Kai; Technik läge mit dem Projekt Scanner des perinco CRM bereits vor (Playwright + Bewertung) |
|
||||
| Morgenlage („Morning Post") aus den zuletzt genannten Unternehmen | Recherchequelle und Zustellweg; Datengrundlage und Modell stehen (`icon_mentions` + `icon_history`, `ai.js`) |
|
||||
| Foto aus dem Lebenslauf ziehen | In der Featureliste als „ggf." vermerkt. Aus DOCX wäre es machbar (eingebettete Medien), aus PDF nur mit zusätzlicher Bibliothek |
|
||||
| Design *Duke Nukem 3D* | Nur die Farbwerte – Umschalter, Speicherung am Benutzer und die CSS-Haken stehen. Bis dahin bewusst **nicht** in der Auswahl: ein Design, das nichts ändert, sieht nach Fehler aus |
|
||||
Reference in New Issue
Block a user