From ea5f7eb33d7bd2f198f8b4f4429739fbfae346fc Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 14 Sep 2026 15:34:06 +0200 Subject: [PATCH] Dokumentation: README.md --- README.md | 685 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 685 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ef6fbe --- /dev/null +++ b/README.md @@ -0,0 +1,685 @@ +# 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 | + +Half-Life und Pixel Art bringen zusätzlich **Grafiken** hinter der Kopfleiste +(`.app-header`) und dem Menü (`.app-sidebar`) mit – als SVG unter +`public/themes/`. Die Variable enthält dabei **zwei** Ebenen: einen Schleier aus +der Flächenfarbe und darunter die Zeichnung. Das ist kein Umweg, sondern der +Grund, dass dieselbe Grafik in hell und dunkel funktioniert – und die Bedingung +dafür, dass die Schrift lesbar bleibt: in der Kopfleiste liegt der Schleier an +den Rändern dicht und lässt die Grafik nur in der Mitte durch, im Menü liegt er +oben über den Einträgen und gibt nach unten frei. + +Ein Design tauscht ausschließlich Variablenwerte – im Markup steht nirgends eine +feste Farbe. Deshalb kommen Half-Life und ICON mit derselben Oberfläche aus. + +Zwei Entscheidungen zu Half-Life, weil sie sonst als Versehen aussähen: + +* Der Akzent bleibt **Orange und wird nicht zu Warngelb**. Auf `brand-500` und + `brand-600` steht überall weiße Schrift; auf Gelb wäre sie nicht mehr lesbar. + Das Gelb-Schwarz der Gefahrenstreifen kommt deshalb nicht als Fläche vor. +* **Oswald trägt nur die Überschriften und Feldbeschriftungen**, gesperrt in + Versalien. Der Fließtext bleibt Montserrat: die Tabellen haben bis zu acht + Spalten bei 12 px, und eine Schmalschrift wäre darin nicht mehr zu lesen. + Dasselbe gilt für Silkscreen in Pixel Art. + +Die Liste der Designs steht an zwei Stellen und muss übereinstimmen: +`THEME_PALETTES` in `src/types.ts` und `THEMES` in `server/src/userFields.js`. +Das Backend weist einen unbekannten Wert mit 400 ab, damit ein Tippfehler nicht +still zum Standarddesign führt. + +## Dunkelmodus + +Umschalter rechts oben im Kopf; die Wahl liegt im `localStorage` +(`icon_crm_theme_mode`) **und** am Benutzer in der Datenbank, gilt also auf jedem +Gerät. Ohne Wahl folgt die Anwendung der Systemeinstellung, ein Doppelklick auf +den Umschalter stellt das wieder her. + +Umgesetzt ist der Modus **ohne `dark:`-Varianten im Markup**: die Klasse `dark` +auf `` tauscht in `src/index.css` nur die Werte von `canvas`, `surface`, +`ink-*`, `brand-*` und der getönten Statusfarben. Ein Inline-Skript in +`index.html` setzt die Klasse vor dem ersten Frame, sonst blitzt beim Laden die +helle Variante auf. + +## Menü + +``` +Dashboard +Leads Pipeline als Kanban + └ Archiviert alles, was per Aktenschrank weggelegt wurde +Kontakte +Unternehmen +Ausschreibungen Tabelle; nur der laufende Bestand + ├ Live! Kanban über die Phasen + └ Archiv archivierte Ausschreibungen, von dort zurückholbar +Laufende Projekte +Historie +Einstellungen nur für Administratoren +``` + +Die Unterpunkte klappen auf, sobald ein Punkt des Zweigs benutzt wird. +Dauerhaft ausgeklappt wäre die Leiste doppelt so lang, und Archive schaut man +selten an. + +## Stammdaten + +*Einstellungen → Stammdaten* (nur Administratoren) pflegt die Auswahllisten, die +die Formulare speisen: **Branchen** und **Arten der Kontakte**. Beides war vorher +Freitext bzw. fest im Quelltext; beides wächst im Betrieb. + +Zwei Eigenschaften, die in der Oberfläche stehen, weil man sie beim Pflegen +kennen muss: + +* **Umbenennen zieht den Bestand mit.** Sonst trüge ein Unternehmen eine + Branche, die seine Auswahlliste nicht mehr kennt, und das Feld stünde beim + nächsten Öffnen leer. +* **Entfernen geht nur, solange ein Wert nirgends benutzt wird.** Hinter jedem + Eintrag steht deshalb, wie oft er vorkommt. + +Ein Altwert, der nicht mehr in der Liste steht, bleibt im Formular trotzdem +wählbar – sonst würde er beim nächsten Speichern still verschwinden. + +## Architektur + +``` +Browser ──► Apache (:443/:80, icrm.icon-experts.de) + ├── / → /var/www/icon_CRM/dist (SPA, FallbackResource) + └── /api → 127.0.0.1:5007 (Express) + └──► MariaDB icon_CRM +``` + +Der Browser spricht **nie** direkt mit der Datenbank – deshalb das +Express-Backend. Es bindet ausschließlich auf `127.0.0.1` und ist von außen nur +über den Apache-Proxy erreichbar. Das ersetzt den früheren Aufbau, bei dem der +Supabase-Anon-Key im Browser lag und vollen Zugriff auf `icon_users` samt +Passwort-Hashes hatte. + +### Datenmodell + +| Tabelle | Inhalt | +| --- | --- | +| `icon_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, Farbe, Foto, Design | +| `icon_companies` | Unternehmen: Gruppe, Branche, Mitarbeiterzahl, 5-Sterne-Bewertung, `is_customer` + `customer_state` (aktiv/alumni), Freelancer-Einsatz | +| `icon_contacts` | Kontakte – Experten **und** Ansprechpartner, unterschieden über `kind`; inkl. Lead-Quelle und Kontaktstand | +| `icon_employments` | Werdegang: wer war wann bei welchem Unternehmen – speist den Zweig „Verlassen" | +| `icon_interviews` | Wann wurde mit wem gesprochen | +| `icon_history` | Alle Tätigkeiten: Zeitstempel, Benutzer, Text, Feldänderungen | +| `icon_mentions` | Index der `@`-Erwähnungen (Quelle → Ziel) | +| `icon_connections` | Wer kennt wen; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate | +| `icon_tenders`, `icon_tender_contacts` | Ausschreibungen und die zugeordneten Kontakte | +| `icon_projects` | Laufende Projekte | +| `icon_leads`, `icon_lead_todos` | Lead-Pipeline mit Checkliste und Erinnerungen | +| `icon_cv_imports` | CV-Import: Eingang, Screening, Auswahl | +| `icon_options` | Gepflegte Auswahllisten (Branchen, Arten der Kontakte) | +| `icon_settings` | Schlüssel/Wert, auch für Migrationsmerker | + +Zwei Entscheidungen stecken im Modell: + +* **Ein Kunde ist kein eigener Datentyp**, sondern ein Unternehmen mit + `is_customer = 1`. Der Umschalter in der Oberfläche filtert nur darauf – es + gibt keine zweite Liste, die auseinanderlaufen könnte. +* **Experten und Ansprechpartner liegen in einer Tabelle.** Ein Ansprechpartner, + der die Firma wechselt und plötzlich als Experte interessant wird, ist + derselbe Mensch und behält seine Historie. + +### Rollen + +* `admin` – sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltung +* `user` – sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab + +Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator +degradiert, deaktiviert oder gelöscht wird. Ausnahme von der Admin-Regel ist +`GET /api/users/directory`: die Benutzerliste brauchen alle Rollen für +Erwähnungen, Zuständigkeiten und die Historie. Passwort-Hashes liefert der +Endpunkt nie. + +## Struktur + +``` +src/ + App.tsx Layout, gemeinsame Daten, Ansichtswechsel + types.ts Gemeinsame Typen und Konstanten + lib/api.ts Fetch-Wrapper + typisierte Endpunkte + lib/auth.tsx Auth-Context (JWT im localStorage) + lib/theme.tsx Modus und Design + lib/navigation.tsx Karte öffnen aus beliebiger Tiefe + lib/mentions.tsx @-Token: Parser, Renderer, Autocomplete + lib/imageCompress.ts Bilder im Browser auf 400 px verkleinern + components/ + Sidebar.tsx, Header.tsx, GlobalSearch.tsx, Toast.tsx, LoginScreen.tsx + ui/Modal.tsx Dialog + Bestätigung; Hintergrundklick schließt + ui/Avatar.tsx Foto, sonst Initialen; quadratisch für Logos + ui/AvatarPicker.tsx Upload, LinkedIn-Abruf, Entfernen + ui/StarRating.tsx 5-Sterne-Bewertung der Unternehmen + ui/form.tsx Eingabestile, Knöpfe, Pillen, Umschalter + settings/MasterData.tsx Stammdaten: die gepflegten Auswahllisten + contacts/ Liste, Kacheln, Netzwerk, Karte mit Werdegang + companies/ Liste, Kacheln, Group, Netzwerk + network/Network.tsx Die Netzwerkansicht beider Listen (d3-force + SVG) + leads/ Pipeline als Kanban, Archiv, Erinnerungen + tenders/Tenders.tsx Tabelle mit aufklappbarer Zeile (wie im Original) + tenders/TenderText.tsx Ausschreibungstext (WYSIWYG + KI) auf `notes` + tenders/LiveBoard.tsx Ansicht „Live!" – Kanban über die Phasen + projects/ Laufende Projekte + history/ Reiter Historie und der Verlauf je Karte + dashboard/, settings/ +server/ + src/server.js Express-App + src/db.js Pool, Schema-Init, Admin-Seed + src/schema.sql Idempotentes Schema + src/history.js Historie schreiben, Erwähnungen indizieren + src/migrate.js Einmalige Übernahme aus Supabase + src/linkedinPhoto.js Profilfoto aus der Open-Graph-Vorschau + src/middleware/auth.js JWT-Prüfung, Admin-Wächter + src/routes/ auth, users, companies, contacts, tenders, + projects, history, search, network, connections, + dashboard +deploy/ + icon-crm-backend.service systemd-Unit + icrm.icon-experts.de.conf Apache-Vhost + deploy.ps1 Build lokal, Upload, Neustart +legacy/ Der alte Supabase-Stand, nur zum Nachschlagen +``` + +## Kleinigkeiten mit Absicht + +* **„Zuständig" ist beim Anlegen mit dem angemeldeten Benutzer vorbelegt** – in + aller Regel kümmert sich der, der den Datensatz erfasst. Ändern geht weiter. +* **Erwähnungen sind anklickbar.** Im Eingabefeld steht der rohe Token + `@[Name](contact:12)` – anders ließe sich der Text nicht bearbeiten. Darunter + steht deshalb die Lesefassung, und dort führt jede Erwähnung auf das Profil. + +## Historie und Erwähnungen + +Jede schreibende Route ruft `logHistory()` auf, statt selbst in `icon_history` zu +schreiben. Dadurch gilt an einer Stelle, was überall gelten soll: **in jeder +Historie steht, welcher Benutzer was geändert hat.** Bei einer Änderung werden +zusätzlich die betroffenen Felder mit Vorher/Nachher abgelegt. + +Erwähnungen stehen als Token im Text: `@[Anzeigename](contact:12)`, ebenso für +`company` und `user`. Der Name steht mit im Token, damit ein Text auch ohne +geladene Listen lesbar bleibt; beim Rendern gewinnt der aktuelle Name. Jede +Erwähnung wird zusätzlich in `icon_mentions` indiziert – deshalb kann eine Karte +zeigen, wo sie überall genannt wurde, ohne jeden Freitext der Datenbank zu +durchsuchen. + +Ein Arbeitgeberwechsel erzeugt drei Einträge von selbst: beim alten Unternehmen +„hat das Unternehmen verlassen", beim neuen „ist jetzt hier tätig", beim Kontakt +den Wechsel. Daraus speist sich der Zweig **Verlassen** im Stammbaum. + +## Globale Suche + +Ein Feld, zwei Modi – umgeschaltet über die beiden Symbole rechts im Feld: + +**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen, +Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein +Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die +Datenmengen sind klein, und ein `FULLTEXT`-Index würde bei Teilwortsuche – +„informat" soll „Finanz Informatik" finden – nicht helfen. + +**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet von +`gpt-5.6-luna`: *„Wer arbeitet alles bei der KfW?"* → „Bei der KfW ist Gunther +Stammwitz als Ansprechpartner hinterlegt." Die genannten Datensätze kommen als +anklickbare Chips zurück und öffnen ihre Karte. + +Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage +kostet. Und sie erzeugt **kein SQL**: der Bestand wird zusammengefasst und +mitgegeben (`server/src/routes/search.js`). Ein Modell, das Abfragen auf die +Datenbank schreiben darf, wäre eine Angriffsfläche; der Bestand ist klein genug, +um ihn vollständig mitzuliefern. Ab 400 Zeilen je Liste wird gekürzt, und die +Antwort sagt das dazu. + +## KI + +Modell: **`gpt-5.6-luna`** (OpenAI), angesprochen über `chat/completions`. +Schlüssel und Modell stehen ausschließlich in `server/.env` (chmod 600) – der +Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend +(`server/src/ai.js`). Fehlt der Schlüssel, läuft das CRM normal weiter und +blendet die KI-Knöpfe aus. + +> **Fallstrick:** `gpt-5.6-luna` akzeptiert kein eigenes `temperature` und weist +> jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb keines. + +### CV-Import + +*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl +oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB). Der Ablauf folgt der +Featureliste als drei Ordner: + +| Ordner | Bedeutung | +| --- | --- | +| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen“ oder „Nein“ | +| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt | +| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran | + +Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder +Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort +korrigiert wird, gewinnt gegen die KI. + +Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber, +Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen +– die Stundensätze. Frühere Stationen werden als **beendete** Anstellungen +angelegt; dadurch erscheint der Kontakt beim jeweiligen Unternehmen sofort im +Zweig „Verlassen“. Unbekannte Unternehmen legt der Import mit an. + +Die Datei bleibt am Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen +Karte herunterladen. Der Download läuft über `fetch` mit Bearer-Kopf – ein +normaler Link schickt ihn nicht mit. + +### Schreibhilfe für Ausschreibungen + +Das Feld *Ausschreibungstext* (also `icon_tenders.notes`, siehe unten) ist ein +WYSIWYG-Editor (`components/ui/RichTextEditor.tsx`) mit fünf Knöpfen: *Text erzeugen*, +*Verbessern*, *Kürzen*, *Stichpunkte*, *Anforderungsprofil*. Darunter ein Feld +für die Vorgabe („Interim PL SAP S/4HANA, Bank, 12 Monate, 80 % remote“). Der +vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt nichts +unwiderruflich. + +Der Editor kommt ohne Fremdpaket aus: Quill & Co. bringen ihr eigenes +Farbschema mit, das im Dunkelmodus gegen die Variablen dieser Anwendung +arbeitet, und kosten über 200 kB. Gebraucht werden sechs Formate – das leistet +`contentEditable`. Eingefügter Text wird bewusst entformatiert: aus Word oder +einem Portal kämen sonst fremde Schriftgrößen und ``-Wolken mit. + +### Erweiterte Suche und die Sätze + +Die Frage „wer hat den höchsten Satz?" ging lange ins Leere: der Bestand, den +die erweiterte Suche mitbekommt, enthielt die Felder `rate_onsite` und +`rate_remote` gar nicht. Sie sind jetzt dabei, zusammen mit Kontaktstand und +LinkedIn. + +Zwei Dinge stehen dafür ausdrücklich im Systemprompt, weil das Modell sie sonst +falsch beantwortet: + +* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert + steht, und die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist. +* Es sind **Stundensätze**. Fragt jemand nach einem Tagessatz, antwortet die + Suche mit dem Stundensatz und sagt das dazu. + +> **Zur Benennung:** Die Felder heißen `rate_onsite`/`rate_remote` und stammen +> aus `hourlyRateOnsite`/`hourlyRateRemote` des alten CRM, das sie als +> „Stundensatz Onsite (€)" beschriftet und als `€/h` angezeigt hat. Das neue +> CRM hatte sie zwischenzeitlich „Tagessatz" genannt – bei Werten bis 165 € +> wäre das ein sehr günstiger Tag gewesen. Beschriftung und Anzeige tragen die +> Einheit jetzt überall mit. + +## Diktat (Whisper im Browser) + +Überall dort, wo längerer Text entsteht – Notiz am Kontakt, Notizen an Lead, +Unternehmen und Projekt, Ausschreibungstext – sitzt ein Mikrofon-Knopf. Die +Erkennung läuft **vollständig im Browser**: das Backend sieht keine Audiodaten, +es gibt keinen Schlüssel und keine Kosten pro Minute. Aufnahmen werden nirgends +gespeichert. + +Übernommen aus dem perinco CRM, 1:1 in der Technik: + +| Teil | Datei | +| --- | --- | +| Modellverwaltung, Zustand, Cache | `src/lib/whisper.tsx` | +| Erkennung im Web Worker | `src/lib/whisper.worker.ts` | +| Aufnahme (MediaRecorder) | `src/lib/dictation.tsx` | +| Umrechnung auf 16 kHz Mono | `src/lib/audio.ts` | +| Downloadgröße bei Hugging Face | `src/lib/whisperModels.ts` | +| Knopf und Textfeld | `src/components/ui/Dictate.tsx` | +| Reiter *Einstellungen → Diktat* | `src/components/settings/WhisperSettings.tsx` | + +Festgelegt und nicht einstellbar: `onnx-community/whisper-large-v3-turbo`, `q4`, +Sprache Deutsch, Grafikkarte wenn vorhanden (WebGPU), sonst CPU (WASM). Die +Begründung dazu steht im Kopf von `src/lib/whisper.tsx` – kurz: kleinere Modelle +verlieren genau bei den Fachbegriffen, andere Genauigkeiten scheitern am +Speicher oder liefern auf der Grafikkarte Unsinn. + +Das Modell wird **einmal je Browser** geladen (rund 300 MB) und liegt danach im +Cache-Speicher der Seite; die ONNX-Laufzeit liefert das Build selbst aus +(`dist/assets/ort-wasm-simd-threaded.jsep-*.wasm`, 21 MB). Ist es im Cache, +stellt sich das Diktat beim Anmelden von selbst bereit – ein Download von 300 MB +startet nie unaufgefordert. *Einstellungen → Diktat* zeigt den Zustand, eine +Probeaufnahme und den Weg, das Modell wieder loszuwerden. + +Erkannter Text wird **angehängt**, nie ersetzt: in den WYSIWYG-Feldern als +eigener Absatz am Ende, in den Textfeldern hinter dem vorhandenen Text. Ohne +Mikrofon oder ohne HTTPS bleibt der Knopf unsichtbar, ohne geladenes Modell +steht dort *Diktat einrichten* und führt direkt in den Reiter. + +## Ausschreibungen und die ICON Website + +Die Ansicht ist bewusst so aufgebaut wie im ursprünglichen ICON CRM: eine +Tabelle, deren Zeile sich aufklappt – links die zugewiesenen Experten und der +Kundenkontakt, rechts der Ausschreibungstext. + +Zwei Dinge sind dabei keine Kosmetik, sondern hängen an der Website: + +* **Der Ausschreibungstext steht in `icon_tenders.notes`**, als HTML. Nicht in + `details` – dieses Feld ist im gesamten Bestand leer und bleibt ungenutzt. Wer + den Editor auf `details` legt, zeigt ein leeres Feld und schreibt am + veröffentlichten Text vorbei. +* **`online` entscheidet, ob eine Ausschreibung auf der Website erscheint.** Der + Schalter sitzt deshalb direkt in der Liste, einen Klick entfernt. + +Gespeichert wird der Text automatisch, 800 ms nach dem letzten Tastendruck – +wie zuvor auch, es gibt keinen Speichern-Knopf. + +Das Kanban über die Phasen ist nicht Teil dieser Ansicht, sondern steht als +**Live!** im Menü. Anders als früher ist es bearbeitbar: eine Kachel lässt sich +in eine andere Spalte ziehen, und der Wechsel landet in der Historie. + +### Die ICON Website + +Seit dem 14.09.2026 liegt die Website **auf demselben Server** wie das CRM +(`/var/www/icon_website/dist`, Vhost `icon-experts.de` + `www`). Vorher lief sie +auf `212.227.135.41` – derselben Maschine wie perinco CRM, n8n, Docker, Supabase +und ein halbes Dutzend weiterer Dienste, bei 5,5 von 7,9 GB belegtem RAM. + +Der Vhost reicht **`/api/public` an das CRM-Backend durch**. Das ist kein +Schönheitsfehler, sondern der Grund, warum die Content-Security-Policy der Seite +weiter mit `connect-src 'self'` auskommt: der Aufruf ist gleicher Herkunft. +Zeigte die Seite direkt auf `icrm.icon-experts.de`, müsste dieser Host in der CSP +stehen und der Browser bräuchte einen CORS-Vorabruf. Durchgereicht wird +ausschließlich `/api/public` – Anmeldung, Kontakte und Leads bleiben von der +Website aus unerreichbar. + +### Öffentliche Schnittstelle für die Website + +`GET /api/public/tenders` – ohne Anmeldung, nur Lesen, nur Zeilen mit +`online = 1`, neueste zuerst. Dazu `GET /api/public/tenders/:id` für einen +direkten Link. Beide antworten mit `Access-Control-Allow-Origin: *` und +`Cache-Control: public, max-age=60`. + +Ausgeliefert werden genau die Felder, die die Projektbörse anzeigt: + +```json +[{ "id": 8, "title": "…", "status": "Aktiv", "notes": "

…

", + "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 |