feat(kontakte): Zustaendigkeit, LinkedIn-Foto, Netzwerkauswahl

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>
This commit is contained in:
Sascha Wolf
2026-09-09 23:25:37 +02:00
co-authored by Claude Opus 5
parent 3c9d83161e
commit 0a2cf52e8a
14 changed files with 540 additions and 26 deletions
+9 -3
View File
@@ -33,15 +33,21 @@ die Postfachanbindung unter `/api/mail` (siehe
## Ausgehende Verbindungen
Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind
optional - fehlen die Zugangsdaten, läuft die Anwendung ohne den jeweiligen
Teil weiter.
Das Backend spricht von sich aus mit vier fremden Diensten. Alle sind
optional - fehlen die Zugangsdaten oder ist das Ziel nicht erreichbar, läuft die
Anwendung ohne den jeweiligen Teil weiter.
| Ziel | Wofür | Ausgelöst von |
| --- | --- | --- |
| Projektbörsen | Scraping der Projektangebote | Scanner-Scheduler |
| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
| `linkedin.com`, `media.licdn.com` | Profilfoto zu einem Kontakt holen | Benutzer im Kontaktformular |
Der LinkedIn-Abruf ist der einzige, den ein Benutzer direkt auslöst: Er liest die
öffentliche Vorschau (`og:image`) einer Profilseite und lädt das Bild vom
LinkedIn-CDN. Keine Anmeldung, kein Zugriff auf geschützte Inhalte - schlägt der
Abruf fehl, bleibt es beim Hochladen von Hand.
Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten
werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne
+1 -1
View File
@@ -117,7 +117,7 @@ src/
whisper.tsx Modell, Worker-Lebenszyklus
dictation.tsx Aufnahme-Hook
audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern
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
+20
View File
@@ -491,6 +491,21 @@ braucht, pflegt sie im Kontakt nach.
![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png)
Zwei Handgriffe nimmt der Dialog beim **Anlegen** ab:
* **Zuständig** steht schon auf dem angemeldeten Benutzer. Wer für jemand
anderen anlegt, stellt das Feld um; beim späteren Bearbeiten bleibt immer die
gespeicherte Zuständigkeit stehen.
* **Profilfoto von LinkedIn.** Wird eine LinkedIn-Adresse eingetragen und der
Kontakt hat noch kein Foto, holt das CRM beim Verlassen des Feldes das
öffentliche Profilbild und setzt es als Kontaktfoto. Der Knopf **Foto** neben
dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird.
Ein selbst hochgeladenes Foto wird dabei nie überschrieben.
Klappt es nicht, steht der Grund unter dem Feld: Das Profil ist nicht
öffentlich, es hat gar kein eigenes Foto, oder LinkedIn hat die Anfrage
abgewiesen. Dann bleibt der gewohnte Weg über *Ändern* am Foto.
Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
@@ -503,6 +518,11 @@ setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Artfilter wirken auch hier.
Ein Klick auf einen Knoten hebt ihn und seine Nachbarn hervor, alles andere wird
blass. **Ein Klick auf eine freie Stelle nimmt die Hervorhebung wieder zurück** -
das Bild sieht danach aus wie beim Wechsel in die Netzwerkansicht. Verschieben
zählt nicht als Klick: Wer den Hintergrund packt und zieht, behält die Auswahl.
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
+1 -1
View File
@@ -53,7 +53,7 @@ ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Postfachanbindung und Berater-Liste
cd server && npm test # Postfachanbindung, Berater-Liste, Benutzerfelder, LinkedIn-Foto
```
Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines
+34 -1
View File
@@ -123,6 +123,36 @@ unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
### `server/src/linkedinPhoto.js`
Holt das Profilfoto zu einer LinkedIn-Adresse. Bedient wird damit
`POST /api/leads/linkedin-photo`.
| Export | Zweck |
| --- | --- |
| `normalizeProfileUrl(eingabe)` | macht aus `linkedin.com/in/name` eine `https`-URL, wirft bei fremden Hosts |
| `fetchLinkedInPhoto(eingabe)` | Profilseite lesen, `og:image` ziehen, Bild laden, als Data-URL zurückgeben |
| `PhotoError` | Fehler mit `status`; die Route reicht Status und Text unverändert durch |
Der Weg in Stichpunkten und die Gründe dafür:
* **Über das Backend, nicht aus dem Browser.** Ein `fetch` auf linkedin.com aus
der Seite heraus scheitert an der Same-Origin-Policy.
* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar
nicht erst.
* **Nur `og:image`/`twitter:image`**, ersatzweise eine `media.licdn.com`-Adresse
aus dem Quelltext. Es wird nichts angemeldet und nichts Geschütztes gelesen.
* **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen
und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine
beliebige Adresse unterschieben.
* **Platzhalter werden abgewiesen.** Profile ohne eigenes Foto liefern eine
graue Silhouette (`ghost` im Pfad); die soll kein Kontaktfoto werden.
* **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild.
Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen
Anfragen von einer Adresse), kommt eine Meldung im Klartext zurück - das ist
der Normalfall, mit dem die Oberfläche rechnet, kein Serverfehler.
### `server/src/mail/`
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
@@ -212,6 +242,7 @@ Zusagen, auf die sich der Rest verlässt:
| `ApiError` | Fehler mit `status` |
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
| `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen |
| `leadsApi.linkedinPhoto(url)` | Profilfoto zu einer LinkedIn-Adresse als Data-URL; speichert nichts, das erledigt das Formular beim Sichern |
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
@@ -278,6 +309,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass` (Beschriftung als eigene Zeile), `labelTextClass` + `labelRowClass` (Zeile mit Knopf rechts, z. B. Diktat - `min-h-7` haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), `KindBadge`, `Avatar`, Farben der Kontaktart |
| `components/leads/LeadModal.tsx` | Kontaktformular. Beim Neuanlegen ist der angemeldete Benutzer *Zuständig* (`useAuth()`, Rückfallebene `users[0]`) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Neben dem LinkedIn-Feld holt *Foto* das Profilbild; beim Verlassen des Feldes geschieht das einmal je Adresse von selbst, aber nur solange noch kein Bild gesetzt ist (`triedProfiles`) - ein hochgeladenes Foto wird nie überschrieben. Fehler des automatischen Versuchs stehen als Hinweis unter dem Feld, nur der Klick auf *Foto* meldet sich als Toast |
| `components/leads/LeadNetwork.tsx` | Netzwerkdiagramm (d3-force). Ein Klick auf leere Fläche hebt die Auswahl auf, das Diagramm ist danach wieder so hell wie beim Wechsel in die Ansicht; ein Ziehen des Hintergrunds (mehr als 4 px) verschiebt nur und lässt die Auswahl stehen. Knoten stoppen das `pointerdown`, ihre Klicks erreichen den Hintergrund also nicht |
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
@@ -288,7 +321,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG |
| `lib/imageCompress.ts` | `compressImage(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) |
### `src/types.ts`