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