Dokumentation: README.md
This commit is contained in:
@@ -0,0 +1,685 @@
|
|||||||
|
# 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). 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": "<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 |
|
||||||
Reference in New Issue
Block a user