Drei Verbesserungen an den Kontakten: * Beim Anlegen ist der angemeldete Benutzer voreingestellt zustaendig. Bisher stand dort der erste Benutzer der Liste - fast immer falsch. * Wird eine LinkedIn-Adresse hinterlegt, holt das CRM das oeffentliche Profilfoto und setzt es als Kontaktfoto: beim Verlassen des Feldes einmal je Adresse, solange kein Foto gesetzt ist, und auf Klick ueber den Knopf daneben. Der Abruf laeuft ueber das Backend (server/src/linkedinPhoto.js, POST /api/leads/linkedin-photo), weil ihn die Same-Origin-Policy im Browser verhindert. Gelesen wird die Open-Graph-Vorschau der Profilseite; Herkunft, Groesse und Antwortzeit des Bildes werden geprueft, Platzhalter-Silhouetten abgewiesen. Schlaegt der Abruf fehl, steht der Grund unter dem Feld. * In der Netzwerkansicht hebt ein Klick auf freie Flaeche die Auswahl auf - das Diagramm sieht danach aus wie beim Wechsel in die Ansicht. Ein Ziehen des Hintergrunds (ab 4 px) laesst die Auswahl stehen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
05 Frontend
Eine einzige React-Anwendung ohne Router: App.tsx hält die aktuelle Ansicht in
einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung
sitzt und keine tiefen Links braucht.
Menü und Ansichten
flowchart TD
SB["Seitenleiste"] --> K["Kategorien"]
K --> D["Dashboard<br/>(Startansicht, auch über das Logo)"]
K --> AQ["Akquise"]
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
AQ --> AQ3["Papierkorb<br/>von Hand gelöscht; 90 Tage"]
AQ2 -. "Zurück in den Eingang<br/>(Funddatum = heute)" .-> AQ1
AQ3 -. "Wiederherstellen<br/>(Funddatum bleibt)" .-> AQ2
K --> O["Opportunities"]
K --> C["Interviews"]
K --> L["Kontakte"]
L --> L1["Alle Kontakte"]
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
K --> M["Mails"]
M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
SC --> SC3["Suchagenten"]
SC --> SC4["Börsen-Konten"]
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"]
E --> E5["Whisper"]
Postfach und Kontakt-Vorschläge sind Reiter innerhalb der Ansicht Mails, keine Menüpunkte - sie gehören zusammen und wechseln oft hin und her.
Die Menüpunkte Kontakte und Scanner klappen ihre Unterpunkte auf; der
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die
Unterpunkte kommen mit den Beschriftungen zurück. Beide Gruppen entstehen aus
derselben Struktur MenuGroup in Sidebar.tsx.
Aus Mails trägt einen Zähler mit der Anzahl der offenen Funde - er kommt aus
App.tsx, das die Kontakte ohnehin geladen hat.
Zustand und Datenfluss
flowchart TB
subgraph Kontexte["Kontexte in main.tsx"]
TH["ThemeProvider<br/>hell/dunkel/System"]
TO["ToastProvider"]
AU["AuthProvider<br/>JWT, Benutzer"]
WH["WhisperProvider<br/>Modell, Worker"]
end
APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
Kontexte --> APP
APP --> NAV
APP --> DATA
DATA --> V1["Opportunities"]
NOTE["Akquise lädt selbst<br/>acquisitionsApi, nicht über loadData()"]
DATA --> V2["Interviews"]
DATA --> V3["Kontakte<br/>mail_pool = false"]
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
MV["Mailansicht"] -->|"lädt selbst"| MAPI["/api/mail"]
Die CRM-Daten werden einmal zentral geladen und als Props durchgereicht; die Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails, weil sie blättern, suchen und filtern und dabei nicht die ganze Liste im Speicher halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
/api/leads liefert beide Bestände in einem Aufruf; App.tsx teilt sie in
leads (Kontakte) und mailPoolLeads (Vorbereich „Aus Mails") auf. Alle
anderen Ansichten bekommen ausschließlich leads - ein Fund aus einer Mail soll
in keiner Auswahlliste auftauchen. Die Ansicht Aus Mails braucht beides: sie
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
goTo('Kontakte', { leadId }) ist der Sprung aus einer Mailzuordnung zum
Kontakt. App.tsx hält die Id in focusLeadId, die Kontaktansicht öffnet den
Kontakt und meldet mit onFocusHandled() zurück - der Effekt hängt an leads
und wartet damit auf einen gerade erst angelegten Kontakt.
reload={() => loadData(true)} lädt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern lässt.
Komponentenkarte
src/
App.tsx Layout, Datenladen, Ansichtswechsel
types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel für tiefe Komponenten
whisper.tsx Modell, Worker-Lebenszyklus
dictation.tsx Aufnahme-Hook
audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern (auch das LinkedIn-Foto)
components/
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
Vorbereich "Aus Mails" mit Dublettenabgleich
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
scanner/ Dashboard, Unpassend, Suchagenten, Börsen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
Themes ohne dark:-Varianten
Die Darstellung hat zwei unabhängige Achsen, beide als Klasse am <html>:
| Achse | Klasse | Werte |
|---|---|---|
| Farbwelt | theme-perinco, theme-pixel, theme-outdoor, theme-cyberpunk |
perinco ist der Standard |
| Modus | dark bzw. keine |
hell, dunkel, oder der Systemeinstellung folgen |
src/index.css tauscht darin nur Variablenwerte: canvas, surface, ink-*,
brand-*, die getönten Statusfarben, dazu --radius-*, --shadow-* und die
Schriften. Die Komponenten benutzen durchgängig diese Skalen und folgen damit
automatisch - kein dark: und keine themespezifische Klasse im Markup.
flowchart LR
U["Benutzer wählt<br/>im Header"] --> DB["crm_users.theme<br/>crm_users.theme_mode"]
U --> LS["localStorage<br/>nur Zwischenspeicher"]
DB -->|"beim Anmelden"| H["Klassen am html<br/>theme-*, dark"]
LS -->|"vor dem ersten Frame"| H
H --> V["Variablen in index.css<br/>Farben, Radien, Schatten, Schrift"]
V --> W["Utility-Klassen der Komponenten"]
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
Die Wahl gehört dem Benutzer, nicht dem Browser: sie steht in crm_users
und kommt mit /api/auth/me. Wer sich an einem anderen Rechner anmeldet, findet
sein Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht in
die Quere. Der localStorage bleibt daneben als Zwischenspeicher liegen, damit
das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen kann -
sonst blitzt beim Laden das Standarddesign auf. Das hat genau eine sichtbare
Folge: melden sich zwei Leute an demselben Rechner an, sieht der zweite für einen
Augenblick das Design des ersten.
Der ThemeProvider steht deshalb in main.tsx unter dem AuthProvider -
er liest den Benutzer.
Hintergrundbilder in Leiste und Seitenleiste
Alle Themes außer perinco legen eine Grafik hinter die obere Leiste und die
Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel und Wiese,
Neonröhren und regnerische Gasse. Die Dateien liegen als SVG in
public/themes/ und sind von Hand gezeichnet - stilisierte Grafik statt Foto,
damit sie in jeder Auflösung scharf bleibt und wenige Kilobyte wiegt.
Zwei Anker im Markup: app-header an <header> und app-sidebar an <aside>.
Mehr braucht es nicht, den Rest machen zwei Variablen:
.app-header { background-image: var(--theme-header-image, none); background-size: cover; }
.app-sidebar { background-image: var(--theme-sidebar-image, none); background-position: bottom center; }
Ohne Theme steht in den Variablen nichts, der Rückfall none greift, und
perinco bleibt einfarbig. bg-surface bleibt an beiden Elementen: die Farbe
liegt unter dem Bild und trägt, solange es lädt oder fehlt.
Die Variable enthält zwei Ebenen - einen Schleier aus der Flächenfarbe des Themes und darunter die Grafik:
--theme-header-image: linear-gradient(90deg, rgb(26 32 22 / .95) 0%, rgb(26 32 22 / .6) 55%, …),
url('/themes/outdoor-header.svg');
Das ist kein Umweg, sondern erledigt zwei Dinge zugleich:
- Eine Grafik für beide Modi. Der dunkle Schleier des Dunkelmodus tönt denselben blauen Himmel zur Nacht ab - es braucht keine zweite Datei.
- Lesbarkeit. In der oberen Leiste liegt der Schleier an den Rändern dicht (Titel links, Schalter rechts) und lässt die Grafik nur in der leeren Mitte durch; in der Seitenleiste deckt er oben über den Menüpunkten und gibt nach unten frei, wo die Leiste ohnehin leer ist.
Eine Lehre aus dem ersten Versuch: Die Cyberpunk-Grafik hatte zuerst eine deckende Nachtfläche. Im hellen Modus wurde daraus unter dem weissen Schleier ein grauer Schmutzstreifen. Seither ist sie durchsichtig und zeigt nur Licht - über Dunkel leuchtet das Neon, über Weiss bleiben blasse farbige Linien. Wer eine Grafik ergänzt: keine deckenden Flächen, nur Motiv.
Was ein neues Theme können muss
Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie verletzt, macht Text unlesbar. Nachgezählt im Markup:
| Skala | Rolle | Verwendungen |
|---|---|---|
ink-50, ink-100 |
Flächen und Hover | 102 × bg- |
ink-200 |
Rahmen | 224 × border-, 1 × bg- |
ink-300–ink-900 |
Text; ink-400 trägt die Hinweistexte |
241 × text-ink-400 |
ink-950 |
nur unter Dialog-Overlays, bleibt immer dunkel | 5 × bg- |
brand-50, brand-100 |
getönte Flächen | 70 × bg- |
brand-200–brand-400 |
Rahmen | 76 × border- |
brand-500, brand-600 |
gefüllte Knöpfe - weisse Schrift darauf | 100 × bg- |
brand-600–brand-800 |
Text auf getönten Flächen | 108 × text- |
Daraus folgen zwei harte Regeln:
brand-500undbrand-600müssen dunkel genug für weisse Schrift sein.text-whitesteht 69 × im Markup, immer auf einer Markenfläche. Ein helles Theme kann dort kein Pastell verwenden.- Der helle Block eines Themes braucht
:not(.dark).html.theme-xhat eine Klasse mehr in der Spezifität als.darkund würde den Dunkelmodus sonst überstimmen.
Was ein Theme über Farben hinaus ändern kann: Eckenradien (--radius-*, weil
Tailwind v4 rounded-xl zu var(--radius-xl) auflöst), Schatten (--shadow-*)
und die Schriften (--font-sans für Fliesstext, --font-display für
Überschriften und Feldbeschriftungen - letztere tragen dafür die Klasse
field-label). rounded-full ist die eine Ausnahme: dort schreibt Tailwind
einen festen Wert, deshalb hat Pixel Art dafür eine eigene Regel.
Drei Werte bleiben in jedem Theme und Modus gleich: ink-950 (liegt nur unter
Dialog-Overlays), text-white (steht immer auf farbigen Flächen) und die 39
festen Hex-Werte für Phasenfarben, Benutzerfarben und Diagramme - die folgen
bewusst keinem Theme, weil sie Bedeutung tragen und nicht Dekoration sind.
Eine bewusste Ausnahme: der Mailinhalt bleibt auch im Dunkelmodus auf weissem Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund darunter macht schwarze Schrift auf schwarzer Fläche. Der Rahmen liest sich damit wie ein Blatt Papier im dunklen Fenster - gewollt.