Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf. - Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu) - Neuer Endpunkt GET /api/ai/models (admin) - Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet; Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist - Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen, Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.8 KiB
02 Datenmodell
Eine Datenbank, icon_CRM, alle Tabellen mit dem Präfix icon_. Das Schema
steht als idempotentes SQL in server/src/schema.sql und wird beim Start des
Backends angewandt; spätere Feldänderungen liegen als Migrationen in
server/src/db.js.
Überblick
erDiagram
icon_users ||--o{ icon_companies : "erstellt / zustaendig"
icon_users ||--o{ icon_contacts : "erstellt / zustaendig"
icon_users ||--o{ icon_history : "hat getan"
icon_companies ||--o{ icon_contacts : "beschaeftigt"
icon_companies ||--o{ icon_employments : "Stationen"
icon_contacts ||--o{ icon_employments : "Werdegang"
icon_contacts ||--o{ icon_interviews : "Gespraeche"
icon_contacts ||--o{ icon_connections : "kennt"
icon_companies ||--o{ icon_tenders : "Kunde"
icon_tenders ||--o{ icon_tender_contacts : "zugewiesen"
icon_contacts ||--o{ icon_tender_contacts : "zugewiesen"
icon_companies ||--o{ icon_projects : "Kunde"
icon_contacts ||--o{ icon_projects : "Experte"
icon_companies ||--o{ icon_leads : "Ziel"
icon_contacts ||--o{ icon_leads : "Ansprechpartner"
icon_leads ||--o{ icon_lead_todos : "Checkliste"
icon_contacts ||--o{ icon_cv_imports : "wurde daraus"
icon_mentions, icon_options und icon_settings hängen an keiner
Fremdschlüsselbeziehung und stehen deshalb nicht im Bild.
Die Tabellen im Einzelnen
icon_users
| Feld | Typ | Bedeutung |
|---|---|---|
username, email |
VARCHAR, je eindeutig | Anmeldung geht mit beidem |
name |
VARCHAR | Anzeigename |
role |
ENUM admin,user |
siehe 01 Architektur |
password_hash |
VARCHAR(255) | bcrypt; Altbestand SHA-256, wird beim Login umgeschrieben |
color |
VARCHAR(9) | persönliche Farbe für Avatar und Historie |
avatar |
MEDIUMTEXT | Foto als Data-URL |
theme, theme_mode |
VARCHAR / ENUM | Design und hell/dunkel, gilt geräteübergreifend |
is_active |
TINYINT | deaktiviert = sofort ausgesperrt |
icon_companies
name, group_name (Konzern), industry (aus den Stammdaten), employees,
website, address, logo (Data-URL), rating (0–5 Sterne), is_customer,
customer_state (aktiv/alumni), freelancers
(unbekannt/ja/nein), notes, created_by, owner_id.
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.
icon_contacts
| Feld | Bedeutung |
|---|---|
first_name, last_name, position |
Person |
email, phone, linkedin, avatar |
Kontaktdaten und Foto |
company_id |
aktueller Arbeitgeber |
kind |
Art: Experte, Ansprechpartner, Lead – seit einer Migration VARCHAR statt ENUM, weil die Liste in den Stammdaten wächst |
status |
Interessiert, Interview, Gebucht, Inaktiv |
lead_source |
woher der Kontakt kam |
contact_state |
unbekannt / persönlich / kein Kontakt |
freelancers |
arbeitet freiberuflich |
bio |
Notiz, darf @-Erwähnungen enthalten |
tags |
Schlagworte, kommagetrennt |
rate_onsite, rate_remote |
Stundensätze in Euro, NULL = nicht hinterlegt |
cv_url, cv_file |
Link bzw. hochgelegter Lebenslauf |
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.
rate_onsite/rate_remotestammen aushourlyRateOnsite/hourlyRateRemotedes alten CRM und sind Stundensätze. Die Oberfläche schreibt die Einheit überall mit (125,00 €/h), und die erweiterte Suche weiß es ebenfalls.
icon_employments
Werdegang: contact_id, company_id, position, started_on, ended_on,
is_current. Daraus speist sich der Zweig Verlassen im Stammbaum eines
Unternehmens. Ein Arbeitgeberwechsel schreibt hier automatisch fort; daneben
lassen sich Stationen in der Kontaktkarte von Hand pflegen.
Zwei Regeln halten Station und Kontakt zusammen (server/src/routes/contacts.js):
- Genau eine Station kann
is_current = 1sein. Wird eine dazu erklärt, bekommen die übrigen ein Enddatum. - Die laufende Station ist der Arbeitgeber:
icon_contacts.company_idwird mitgeführt – wird sie beendet oder gelöscht, steht dortNULL. Beides auseinanderlaufen zu lassen wäre die Quelle für „im Kontakt steht A, im Werdegang B".
icon_interviews
contact_id oder company_id, user_id, held_on, notes – wann wurde mit
wem gesprochen.
icon_history
Jede Tätigkeit: entity_type (contact, company, tender, project, lead, user,
system), entity_id, entity_label, action, text, changes (JSON mit
Vorher/Nachher je Feld), user_id, created_at.
Geschrieben wird ausschließlich über logHistory() in
server/src/history.js – nicht von den Routen selbst. Dadurch gilt an einer
Stelle, was überall gelten soll: in jeder Historie steht, welcher Benutzer was
geändert hat.
icon_mentions
Index der @-Erwähnungen: source_type/source_id → target_type/target_id,
eindeutig je Paar. Ohne diesen Index müsste eine Karte jeden Freitext der
Datenbank durchsuchen, um zu zeigen, wo sie genannt wurde.
icon_connections
Wer kennt wen: contact_a, contact_b, kind, strength. Das Paar wird
aufsteigend gespeichert, ein Unique-Key verhindert, dass dieselbe Verbindung
zweimal entsteht.
icon_tenders, icon_tender_contacts
| Feld | Bedeutung |
|---|---|
title, source, link |
Ausschreibung und Herkunft |
company_id |
Kunde |
status |
Aktiv, Inaktiv, Ausgelaufen, Archiviert |
stage |
Phase: Ausschreibung → Kontakte sichten → Interview → Kunden Interview → Vertragsgestaltung → Abschluss → Geschlossen |
online |
erscheint auf der ICON Website |
notes |
der veröffentlichte Ausschreibungstext, als HTML |
details |
im gesamten Bestand leer, ungenutzt |
deadline |
Frist |
Die wichtigste Zeile dieses Kapitels: der Text der Website steht in
notes, nicht indetails. Wer den Editor aufdetailslegt, zeigt ein leeres Feld und schreibt am veröffentlichten Text vorbei.
icon_tender_contacts verbindet Ausschreibung und Kontakt (role = Rolle im
Projekt).
icon_projects
title, company_id, contact_id, started_on, ended_on, status
(Aktiv / Nicht aktiv / Abgeschlossen), rate, notes (HTML). Im Menü heißt die
Ansicht Laufende Projekte.
rate ist ein Tagessatz in Euro – das, was ICON dem Kunden berechnet. Beim
Berater stehen dagegen Stundensätze (rate_onsite, rate_remote in
icon_contacts). Die Marge rechnet mit 1 Tag = 8 Stunden:
Marge = rate − Stundensatz des Beraters × 8
Die Marge wird nicht gespeichert. Sie ist eine Rechnung aus zwei Feldern,
die sich beide ändern können – ein gespeicherter Wert wäre nach der ersten
Satzänderung falsch, ohne dass es jemand merkt. GET /api/projects liefert die
beiden Sätze des Beraters deshalb als contact_rate_onsite /
contact_rate_remote mit; gerechnet wird in src/components/projects/margin.ts.
Es gibt am Projekt kein Feld für die Einsatzart. Vorrang hat der Remote-Satz; nur wenn er fehlt, wird der Vor-Ort-Satz genommen. Welcher es war, steht in der Oberfläche dabei.
Im übernommenen Bestand steht im Kopf von notes ein Textblock aus dem alten
CRM (KUNDE:, KONTAKT:, EXPERTE:, START:, ENDE:), darunter der
übernommene Ausschreibungstext als HTML. src/lib/richText.ts macht daraus im
Editor Absätze plus HTML – deshalb der Sonderfall „Text, dann HTML".
icon_leads, icon_lead_todos
title, company_id, contact_id, stage (acht Phasen, erste = Fuer Spaeter, in der Oberfläche Backlog), contact_state, archived,
position (Reihenfolge in der Spalte), notes, remind_at, reminder_days,
last_contact_at, owner_id.
icon_lead_todos: lead_id, text, done, due_on, position – die
Checkliste, deren Fortschritt auf der Kachel als Prozentwert steht.
icon_cv_imports
filename, original_name, mime, size_bytes, status
(eingang → screening → auswahl, oder verworfen), extracted (das JSON
der KI), photo, error, contact_id (nach der Übernahme), uploaded_by.
photo ist das im Lebenslauf gefundene Bewerbungsfoto, unverändert als
Data-URL. Es ist eine Zwischenablage: beim Anlegen des Kontakts geht das
Bild verkleinert an icon_contacts.avatar und wird hier geleert.
icon_options
Die gepflegten Auswahllisten: field (industry, contact_kind), value,
position. Umbenennen zieht den Bestand mit; Entfernen geht nur, solange ein
Wert nirgends benutzt wird.
icon_settings
Schlüssel/Wert, unter anderem für Migrationsmerker – damit eine einmalige Umstellung nicht bei jedem Start erneut läuft.
Migration aus Supabase
server/src/migrate.js übernimmt den Altbestand einmalig und wiederholbar
(Unternehmen und Kontakte werden über ihren Namen wiedererkannt):
| alt | neu |
|---|---|
icon_customers |
icon_companies mit is_customer = 1 |
icon_contacts |
icon_contacts, kind = 'Ansprechpartner' |
icon_experts |
icon_contacts, kind = 'Experte' |
hourlyRateOnsite, hourlyRateRemote |
rate_onsite, rate_remote |
Status Verfügbar |
Interessiert |
kanbanStatus: Experten sichten |
Phase Kontakte sichten |
cd /var/www/icon_CRM/server
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js # Probelauf
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js --write # schreibt