Files
schakal00783andClaude Opus 5.5 a24fd938a2 feat: Theme Snow aus Pulse übernommen
Eisblau auf kaltem Weiß (hell) bzw. Winternacht (dunkel), verschneite
Berg- und Waldgrafik hinter Kopfleiste und Menü. Schneefall im Canvas
(SnowWeather) sammelt sich am unteren Rand, alle 4 Minuten räumt ein
Pflug in ICON-Gold. Canvas liegt mit z-[45] unter den Dialogen (z-50).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:45:35 +02:00

36 KiB
Raw Permalink Blame History

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

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

[{ "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.

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