Neues Feld alarm_min_score (0-100 %): Alarm nur, wenn ein Alarm-Schlagwort gefunden wurde und die Quote den Wert erreicht. 0 % = bei jedem Fund wie bisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 |
| Snow | Winter: Eisblau auf kaltem Weiß, verschneite Berglandschaft; Schneefall sammelt sich am unteren Rand, alle 4 Minuten fährt ein Räumfahrzeug durch (src/components/ui/SnowWeather.tsx, aus Pulse übernommen) |
Half-Life, Pixel Art und Snow 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-500undbrand-600steht ü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. Nutzerverwaltunguser– 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. 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.
Schlüssel und Modell stehen unter Einstellungen → KI (nur Administratoren) und
liegen in icon_settings; server/.env bleibt Startwert und Rückfall, die
Datenbank gewinnt. Angezeigt wird der Schlüssel nur verkürzt, das Eingabefeld
schreibt nur, und ein Testknopf prüft Schlüssel, Modellname und Kontingent in
einem Aufruf.
Fallstrick:
gpt-5.6-lunaakzeptiert kein eigenestemperatureund weist jeden Wert außer dem Standard mit HTTP 400 ab.ai.jsschickt 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.
Trägt der Lebenslauf ein Bewerbungsfoto, wird es mit herausgeholt
(server/src/cvPhoto.js, ohne zusätzliches Paket: DOCX über das eingebaute
zlib, PDF über die Markierungen der eingebetteten JPEGs). Welches der
gefundenen Bilder das Portrait ist, entscheidet die Form – zu klein ist ein
Symbol, zu breit eine Kopfzeile, und Hochformat gewinnt gegen Querformat. Es
steht im Screening-Formular mit Haken, wird im Browser auf 400 px und höchstens
512 kB verkleinert und landet beim Übernehmen als Foto am Kontakt.
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_remoteund stammen aushourlyRateOnsite/hourlyRateRemotedes alten CRM, das sie als „Stundensatz Onsite (€)" beschriftet und als€/hangezeigt 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). Einstellungen →
Diktat zeigt den Zustand, eine Probeaufnahme und den Weg, das Modell wieder
loszuwerden.
Nichts davon läuft von selbst an – auch nicht aus dem Cache. Das Anlegen der ONNX-Sitzung übersetzt hunderte Shader und legt dabei Zeiger und Darstellung für einige Sekunden still; als das beim Anmelden von allein geschah, sah die ganze Anwendung aus, als sei sie abgestürzt. Aktiviert wird deshalb je Sitzung mit einem Klick.
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.
Werdegang
Die Stationen eines Kontakts entstehen an drei Stellen von selbst: beim Setzen eines Arbeitgebers, beim Wechsel und beim CV-Import. Das genügt nicht – kein Lebenslauf wird vollständig erkannt, und ein falsch geschriebener Firmenname ließ sich gar nicht mehr geradeziehen. In der Kontaktkarte (Werdegang & Interviews) lässt sich deshalb jede Station anlegen, ändern und entfernen.
Das Unternehmen steht als Textfeld mit Vorschlagsliste da und nicht als Auswahlfeld: ein Kunde aus einem Lebenslauf ist häufig noch nicht im CRM, und ihn erst unter Unternehmen anzulegen wäre ein Umweg. Ein unbekannter Name legt das Unternehmen an – wie beim CV-Import auch.
Genau eine Station kann laufen, und sie ist zugleich der Arbeitgeber des
Kontakts. Wird eine Station als aktuell markiert, bekommt die bisherige ein
Enddatum und icon_contacts.company_id wechselt mit; wird die laufende Station
gelöscht, steht dort danach nichts. Alles andere wäre die Quelle für „im Kontakt
steht A, im Werdegang B".
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 indetails– dieses Feld ist im gesamten Bestand leer und bleibt ungenutzt. Wer den Editor aufdetailslegt, zeigt ein leeres Feld und schreibt am veröffentlichten Text vorbei. onlineentscheidet, 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:
[{ "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 = 1und 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_atauf 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.
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
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.
$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)
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 |