# ICON CRM CRM im ICON Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB. Läuft auf . 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 `` 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 ``-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: ```json [{ "id": 8, "title": "…", "status": "Aktiv", "notes": "

…

", "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= node src/migrate.js SUPABASE_URL=https://icondb.perinco.de SUPABASE_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 |