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
+35 -3
View File
@@ -340,7 +340,15 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
return () => element.removeEventListener('wheel', onWheel);
}, []);
const panStateRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
const panStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
moved: boolean;
} | null>(null);
/** Ab dieser Verschiebung gilt die Geste als Ziehen und nicht mehr als Klick. */
const CLICK_TOLERANCE = 4;
const handleBackgroundPointerDown = (event: React.PointerEvent) => {
if (event.button !== 0) return;
@@ -348,6 +356,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
pointerId: event.pointerId,
startX: event.clientX - transform.x,
startY: event.clientY - transform.y,
moved: false,
};
(event.currentTarget as Element).setPointerCapture(event.pointerId);
};
@@ -355,10 +364,33 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
const handleBackgroundPointerMove = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
setTransform((prev) => ({ ...prev, x: event.clientX - pan.startX, y: event.clientY - pan.startY }));
const x = event.clientX - pan.startX;
const y = event.clientY - pan.startY;
if (Math.abs(x - transform.x) > CLICK_TOLERANCE || Math.abs(y - transform.y) > CLICK_TOLERANCE) {
pan.moved = true;
}
setTransform((prev) => ({ ...prev, x, y }));
};
/*
* Ein Klick ins Leere hebt die Auswahl auf. Damit sieht das Netz wieder so
* aus wie beim Wechsel auf die Ansicht: nichts ist ausgegraut. Ein Ziehen
* (Verschieben der Ansicht) darf die Auswahl dagegen nicht verwerfen,
* deshalb der Vergleich mit der Toleranz. Knoten stoppen das Ereignis
* bereits beim pointerdown, ihre Klicks landen hier gar nicht erst.
*/
const handleBackgroundPointerUp = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (pan?.pointerId !== event.pointerId) return;
panStateRef.current = null;
if (pan.moved) return;
setSelectedId(null);
setHoveredId(null);
if (linkMode) setLinkSourceId(null);
};
/** Verlaesst der Zeiger die Flaeche, endet die Geste ohne Auswirkung auf die Auswahl. */
const handleBackgroundPointerLeave = (event: React.PointerEvent) => {
if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null;
};
@@ -654,7 +686,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
handleNodePointerMove(event);
}}
onPointerUp={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerLeave}
className="h-full w-full cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
>