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:
co-authored by
Claude Opus 5
parent
3c9d83161e
commit
0a2cf52e8a
@@ -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
@@ -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
|
||||
|
||||
@@ -491,6 +491,21 @@ braucht, pflegt sie im Kontakt nach.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user