Files
perinco-CRM/src/components/leads/LeadNetwork.tsx
T
Sascha WolfandClaude Opus 5 0a2cf52e8a 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>
2026-09-09 23:25:37 +02:00

1034 lines
39 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from 'd3-force';
import { KIND_COLORS } from './leadUi';
import { connectionsApi } from '../../lib/api';
import { useToast } from '../Toast';
import {
CONNECTION_TYPES,
leadFullName,
leadInitials,
type AppUser,
type ConnectionType,
type Lead,
type LeadConnection,
} from '../../types';
interface GraphNode extends SimulationNodeDatum {
id: string;
kind: 'lead' | 'user';
label: string;
sublabel: string;
color: string;
radius: number;
degree: number;
avatar: string | null;
leadId?: number;
userId?: number;
}
interface GraphLink extends SimulationLinkDatum<GraphNode> {
id: string;
kind: 'connection' | 'creator';
connectionId?: number;
connectionType?: ConnectionType;
strength: number;
}
interface LeadNetworkProps {
leads: Lead[];
connections: LeadConnection[];
users: AppUser[];
onEditLead: (lead: Lead) => void;
reload: () => void;
}
// Im SVG lassen sich keine Tailwind-Klassen fuer fill/stroke stapeln, deshalb
// die Theme-Variablen direkt - so folgt das Diagramm dem Dark Mode.
const SURFACE = 'var(--color-surface)';
const USER_COLOR = 'var(--color-ink-500)';
const CREATOR_LINK_COLOR = 'var(--color-ink-300)';
const CONNECTION_LINK_COLOR = 'var(--color-brand-500)';
const LABEL_COLOR = 'var(--color-ink-800)';
const SUBLABEL_COLOR = 'var(--color-ink-400)';
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
function nodeId(kind: 'lead' | 'user', id: number) {
return `${kind}-${id}`;
}
export const LeadNetwork: React.FC<LeadNetworkProps> = ({
leads,
connections,
users,
onEditLead,
reload,
}) => {
const { addToast } = useToast();
const containerRef = useRef<HTMLDivElement>(null);
const simulationRef = useRef<Simulation<GraphNode, GraphLink> | null>(null);
const frameRef = useRef<number | null>(null);
const isRunningRef = useRef(false);
/** Merkt Knotenpositionen, damit ein Datenrefresh das Layout nicht zerstoert. */
const positionsRef = useRef<Map<string, { x: number; y: number; fx?: number; fy?: number }>>(new Map());
const [showCreators, setShowCreators] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 });
const [size, setSize] = useState({ width: 800, height: 600 });
const [, setFrame] = useState(0);
// Verbindungsmodus: erst Knoten A, dann Knoten B anklicken.
const [linkMode, setLinkMode] = useState(false);
const [linkSourceId, setLinkSourceId] = useState<number | null>(null);
const [linkType, setLinkType] = useState<ConnectionType>('Netzwerk');
const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]);
// ---------- Graph aufbauen ----------
const graph = useMemo(() => {
const visibleLeadIds = new Set(leads.map((lead) => lead.id));
const degree = new Map<string, number>();
const bump = (key: string) => degree.set(key, (degree.get(key) ?? 0) + 1);
const relevantConnections = connections.filter(
(connection) => visibleLeadIds.has(connection.from_lead_id) && visibleLeadIds.has(connection.to_lead_id)
);
for (const connection of relevantConnections) {
bump(nodeId('lead', connection.from_lead_id));
bump(nodeId('lead', connection.to_lead_id));
}
const creatorIds = new Set<number>();
if (showCreators) {
for (const lead of leads) {
if (lead.created_by === null) continue;
creatorIds.add(lead.created_by);
bump(nodeId('user', lead.created_by));
bump(nodeId('lead', lead.id));
}
}
const nodes: GraphNode[] = leads.map((lead) => {
const id = nodeId('lead', lead.id);
const deg = degree.get(id) ?? 0;
return {
id,
kind: 'lead',
leadId: lead.id,
label: leadFullName(lead),
sublabel: lead.company || lead.position || '',
color: KIND_COLORS[lead.kind],
degree: deg,
radius: 14 + Math.min(deg, 8) * 1.8,
avatar: lead.avatar,
};
});
const userById = new Map(users.map((user) => [user.id, user]));
for (const userId of creatorIds) {
const id = nodeId('user', userId);
const deg = degree.get(id) ?? 0;
const user = userById.get(userId);
nodes.push({
id,
kind: 'user',
userId,
label: user?.name ?? `Benutzer #${userId}`,
sublabel: user?.role === 'admin' ? 'Administrator' : 'Benutzer',
color: USER_COLOR,
degree: deg,
radius: 18 + Math.min(deg, 12) * 1.2,
avatar: user?.avatar ?? null,
});
}
const links: GraphLink[] = relevantConnections.map((connection) => ({
id: `conn-${connection.id}`,
kind: 'connection',
connectionId: connection.id,
connectionType: connection.type,
strength: connection.strength,
source: nodeId('lead', connection.from_lead_id),
target: nodeId('lead', connection.to_lead_id),
}));
if (showCreators) {
for (const lead of leads) {
if (lead.created_by === null) continue;
links.push({
id: `creator-${lead.created_by}-${lead.id}`,
kind: 'creator',
strength: 1,
source: nodeId('user', lead.created_by),
target: nodeId('lead', lead.id),
});
}
}
return { nodes, links };
}, [leads, connections, users, showCreators]);
// ---------- Groesse beobachten ----------
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) setSize({ width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
// ---------- Simulation ----------
const stopLoop = useCallback(() => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
isRunningRef.current = false;
}, []);
const startLoop = useCallback(() => {
if (isRunningRef.current) return;
isRunningRef.current = true;
const step = () => {
const simulation = simulationRef.current;
if (!simulation) {
isRunningRef.current = false;
return;
}
// Mehrere Ticks pro Frame: das Layout beruhigt sich schneller, ohne die
// Renderrate zu erhoehen.
simulation.tick(2);
setFrame((value) => value + 1);
if (simulation.alpha() <= simulation.alphaMin()) {
// Endzustand einmal sichern und Loop beenden.
for (const node of simulation.nodes()) {
positionsRef.current.set(node.id, {
x: node.x ?? 0,
y: node.y ?? 0,
fx: node.fx ?? undefined,
fy: node.fy ?? undefined,
});
}
isRunningRef.current = false;
frameRef.current = null;
return;
}
frameRef.current = requestAnimationFrame(step);
};
frameRef.current = requestAnimationFrame(step);
}, []);
useEffect(() => {
// Vorherige Positionen uebernehmen, neue Knoten am Rand einstreuen.
const nodes = graph.nodes.map((node, index) => {
const saved = positionsRef.current.get(node.id);
if (saved) return { ...node, ...saved };
const angle = (index / Math.max(graph.nodes.length, 1)) * Math.PI * 2;
return { ...node, x: Math.cos(angle) * 220, y: Math.sin(angle) * 220 };
});
const simulation = forceSimulation<GraphNode, GraphLink>(nodes)
.force(
'link',
forceLink<GraphNode, GraphLink>(graph.links.map((link) => ({ ...link })))
.id((node) => node.id)
.distance((link) => (link.kind === 'creator' ? 150 : 70 + (3 - link.strength) * 25))
.strength((link) => (link.kind === 'creator' ? 0.18 : 0.55))
)
.force('charge', forceManyBody<GraphNode>().strength((node) => (node.kind === 'user' ? -700 : -320)))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide<GraphNode>((node) => node.radius + 12))
.force('x', forceX(0).strength(0.02))
.force('y', forceY(0).strength(0.02))
.alphaMin(0.004)
.alphaDecay(0.025)
.stop();
simulationRef.current = simulation;
startLoop();
return () => {
stopLoop();
simulation.stop();
simulationRef.current = null;
};
// graph aendert sich nur bei echten Datenaenderungen (useMemo).
}, [graph, startLoop, stopLoop]);
const reheat = useCallback(
(alpha = 0.35) => {
const simulation = simulationRef.current;
if (!simulation) return;
simulation.alpha(alpha);
startLoop();
},
[startLoop]
);
const simulationNodes = simulationRef.current?.nodes() ?? [];
const simulationLinks =
(simulationRef.current?.force('link') as ReturnType<typeof forceLink<GraphNode, GraphLink>> | undefined)?.links() ??
[];
// ---------- Nachbarschaft fuer Hervorhebung ----------
const focusId = hoveredId ?? selectedId;
const neighbourIds = useMemo(() => {
if (!focusId) return null;
const ids = new Set<string>([focusId]);
for (const link of simulationLinks) {
const source = link.source as GraphNode;
const target = link.target as GraphNode;
if (source.id === focusId) ids.add(target.id);
if (target.id === focusId) ids.add(source.id);
}
return ids;
// simulationLinks ist stabil, solange sich der Graph nicht aendert.
}, [focusId, graph]);
// ---------- Pan / Zoom ----------
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = element.getBoundingClientRect();
// Cursorposition relativ zum Diagrammzentrum - die Gruppe wird mit
// translate(x + width/2, y + height/2) verschoben.
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
setTransform((prev) => {
const factor = Math.exp(-event.deltaY * 0.0016);
const k = Math.min(Math.max(prev.k * factor, MIN_ZOOM), MAX_ZOOM);
const scale = k / prev.k;
// Der Punkt unter dem Cursor bleibt beim Zoomen an derselben Stelle.
return {
k,
x: cursorX - (cursorX - prev.x) * scale,
y: cursorY - (cursorY - prev.y) * scale,
};
});
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, []);
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;
panStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX - transform.x,
startY: event.clientY - transform.y,
moved: false,
};
(event.currentTarget as Element).setPointerCapture(event.pointerId);
};
const handleBackgroundPointerMove = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
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;
};
/** Bildschirm- in Simulationskoordinaten umrechnen. */
const toSimCoords = useCallback(
(clientX: number, clientY: number) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
return {
x: (clientX - rect.left - transform.x - size.width / 2) / transform.k,
y: (clientY - rect.top - transform.y - size.height / 2) / transform.k,
};
},
[transform, size]
);
// ---------- Knoten ziehen ----------
const dragStateRef = useRef<{ pointerId: number; node: GraphNode; moved: boolean } | null>(null);
const handleNodePointerDown = (event: React.PointerEvent, node: GraphNode) => {
if (event.button !== 0) return;
event.stopPropagation();
dragStateRef.current = { pointerId: event.pointerId, node, moved: false };
(event.currentTarget as Element).setPointerCapture(event.pointerId);
const point = toSimCoords(event.clientX, event.clientY);
node.fx = point.x;
node.fy = point.y;
reheat(0.2);
};
const handleNodePointerMove = (event: React.PointerEvent) => {
const drag = dragStateRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const point = toSimCoords(event.clientX, event.clientY);
drag.node.fx = point.x;
drag.node.fy = point.y;
drag.moved = true;
reheat(0.2);
};
const handleNodePointerUp = (event: React.PointerEvent, node: GraphNode) => {
const drag = dragStateRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragStateRef.current = null;
// Gezogene Knoten bleiben fixiert - so laesst sich das Diagramm bewusst
// arrangieren. Doppelklick loest die Fixierung wieder.
positionsRef.current.set(node.id, {
x: node.x ?? 0,
y: node.y ?? 0,
fx: node.fx ?? undefined,
fy: node.fy ?? undefined,
});
if (!drag.moved) handleNodeClick(node);
};
const handleNodeDoubleClick = (node: GraphNode) => {
node.fx = null;
node.fy = null;
positionsRef.current.set(node.id, { x: node.x ?? 0, y: node.y ?? 0 });
reheat(0.3);
};
// ---------- Auswahl / Verbindungsmodus ----------
const createConnection = async (fromLeadId: number, toLeadId: number) => {
try {
await connectionsApi.create({
from_lead_id: fromLeadId,
to_lead_id: toLeadId,
type: linkType,
});
addToast('Verbindung angelegt.', 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.', 'error');
} finally {
setLinkSourceId(null);
setLinkMode(false);
}
};
const handleNodeClick = (node: GraphNode) => {
if (linkMode) {
if (node.kind !== 'lead' || node.leadId === undefined) {
addToast('Verbindungen lassen sich nur zwischen Kontakten anlegen.', 'error');
return;
}
if (linkSourceId === null) {
setLinkSourceId(node.leadId);
return;
}
if (linkSourceId === node.leadId) {
setLinkSourceId(null);
return;
}
createConnection(linkSourceId, node.leadId);
return;
}
setSelectedId((current) => (current === node.id ? null : node.id));
};
const removeConnection = async (connectionId: number) => {
try {
await connectionsApi.remove(connectionId);
addToast('Verbindung entfernt.', 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
}
};
const resetView = () => {
setTransform({ x: 0, y: 0, k: 1 });
};
const resetLayout = () => {
positionsRef.current.clear();
for (const node of simulationNodes) {
node.fx = null;
node.fy = null;
}
reheat(1);
};
// Escape bricht den Verbindungsmodus ab.
useEffect(() => {
if (!linkMode) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLinkMode(false);
setLinkSourceId(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [linkMode]);
// ---------- Detailpanel ----------
const selectedNode = simulationNodes.find((node) => node.id === selectedId) ?? null;
const selectedLead =
selectedNode?.kind === 'lead' && selectedNode.leadId !== undefined
? leadById.get(selectedNode.leadId) ?? null
: null;
const selectedConnections = useMemo(() => {
if (!selectedLead) return [];
return connections
.filter((c) => c.from_lead_id === selectedLead.id || c.to_lead_id === selectedLead.id)
.map((c) => {
const otherId = c.from_lead_id === selectedLead.id ? c.to_lead_id : c.from_lead_id;
return { connection: c, other: leadById.get(otherId) ?? null };
})
.filter((entry) => entry.other !== null);
}, [selectedLead, connections, leadById]);
const selectedUserLeads = useMemo(() => {
if (selectedNode?.kind !== 'user' || selectedNode.userId === undefined) return [];
return leads.filter((lead) => lead.created_by === selectedNode.userId);
}, [selectedNode, leads]);
const isDimmed = (id: string) => Boolean(neighbourIds && !neighbourIds.has(id));
return (
<div className="flex h-[calc(100vh-13rem)] min-h-[520px] flex-col gap-3 lg:flex-row">
{/* Diagramm */}
<div className="relative flex-1 overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
{/* Werkzeugleiste */}
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setLinkMode((value) => !value);
setLinkSourceId(null);
}}
className={`rounded-xl px-3 py-2 text-xs font-bold shadow-card transition ${
linkMode
? 'bg-brand-500 text-white'
: 'border border-ink-200 bg-surface text-ink-600 hover:border-brand-300 hover:text-brand-700'
}`}
>
{linkMode ? 'Verbindungsmodus aktiv' : 'Verbindung erstellen'}
</button>
{linkMode && (
<select
value={linkType}
onChange={(event) => setLinkType(event.target.value as ConnectionType)}
className="rounded-xl border border-ink-200 bg-surface px-3 py-2 text-xs font-bold text-ink-700 shadow-card outline-none focus:border-brand-500"
>
{CONNECTION_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
)}
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-ink-200 bg-surface px-3 py-2 text-xs font-bold text-ink-600 shadow-card">
<input
type="checkbox"
checked={showCreators}
onChange={(event) => setShowCreators(event.target.checked)}
className="h-3.5 w-3.5 accent-brand-500"
/>
Ersteller anzeigen
</label>
</div>
{/* Hinweisleiste: ink-950 bleibt in beiden Themes dunkel, ink-900 nicht */}
{linkMode && (
<div className="absolute left-1/2 top-4 z-20 -translate-x-1/2 rounded-xl bg-ink-950 px-4 py-2 text-xs font-bold text-white shadow-lift">
{linkSourceId === null
? 'Ersten Kontakt anklicken'
: `Zweiten Kontakt anklicken (Start: ${
leadById.get(linkSourceId) ? leadFullName(leadById.get(linkSourceId)!) : '?'
}) · Esc bricht ab`}
</div>
)}
{/* Steuerung */}
<div className="absolute bottom-4 right-4 z-20 flex flex-col gap-2">
<button
onClick={() => setTransform((prev) => ({ ...prev, k: Math.min(prev.k * 1.25, MAX_ZOOM) }))}
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600"
title="Vergrößern"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
onClick={() => setTransform((prev) => ({ ...prev, k: Math.max(prev.k / 1.25, MIN_ZOOM) }))}
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600"
title="Verkleinern"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<button
onClick={resetView}
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600"
title="Ansicht zentrieren"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4"
/>
</svg>
</button>
<button
onClick={resetLayout}
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600"
title="Layout neu berechnen"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{/* Legende */}
<div className="absolute bottom-4 left-4 z-20 rounded-xl border border-ink-200 bg-surface/90 px-3 py-2.5 text-[10px] font-bold text-ink-500 shadow-card backdrop-blur">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: CONNECTION_LINK_COLOR }} />
Kontakt &harr; Kontakt
</div>
<div className="mt-1 flex items-center gap-2">
<span className="h-0.5 w-3 border-t border-dashed" style={{ borderColor: USER_COLOR }} />
Ersteller &rarr; Kontakt
</div>
<p className="mt-2 text-[9px] font-semibold uppercase tracking-wider text-ink-300">
Ziehen = verschieben · Rad = Zoom · Doppelklick = lösen
</p>
</div>
<div
ref={containerRef}
onPointerDown={handleBackgroundPointerDown}
onPointerMove={(event) => {
handleBackgroundPointerMove(event);
handleNodePointerMove(event);
}}
onPointerUp={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerLeave}
className="h-full w-full cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
>
{graph.nodes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-ink-100 text-ink-300">
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5m4.5-4.5l1.5-1.5a4 4 0 015.656 5.656l-3 3"
/>
</svg>
</div>
<p className="text-sm font-bold text-ink-500">Keine Kontakte im Netzwerk</p>
<p className="max-w-xs text-xs text-ink-400">
Lege Kontakte an oder passe die Suche an, damit hier Knoten erscheinen.
</p>
</div>
) : (
<svg width={size.width} height={size.height} className="block">
<defs>
{simulationNodes
.filter((node) => node.avatar)
.map((node) => (
// Benutzer sind Quadrate, Kontakte Kreise - der Ausschnitt
// muss zur Form passen, sonst steht das Foto ueber.
<clipPath key={`clip-${node.id}`} id={`clip-${node.id}`}>
{node.kind === 'user' ? (
<rect
x={-(node.radius - 1)}
y={-(node.radius - 1)}
width={(node.radius - 1) * 2}
height={(node.radius - 1) * 2}
rx={5}
/>
) : (
<circle r={node.radius - 1} />
)}
</clipPath>
))}
</defs>
<g transform={`translate(${transform.x + size.width / 2}, ${transform.y + size.height / 2}) scale(${transform.k})`}>
{/* Kanten */}
<g>
{simulationLinks.map((link) => {
const source = link.source as GraphNode;
const target = link.target as GraphNode;
if (source.x === undefined || target.x === undefined) return null;
const dimmed = isDimmed(source.id) || isDimmed(target.id);
const isCreator = link.kind === 'creator';
return (
<line
key={link.id}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={isCreator ? CREATOR_LINK_COLOR : CONNECTION_LINK_COLOR}
strokeWidth={isCreator ? 1 : 0.9 + link.strength * 0.7}
strokeDasharray={isCreator ? '5 4' : undefined}
strokeOpacity={dimmed ? 0.12 : isCreator ? 0.7 : 0.85}
/>
);
})}
</g>
{/* Knoten */}
<g>
{simulationNodes.map((node) => {
if (node.x === undefined || node.y === undefined) return null;
const dimmed = isDimmed(node.id);
const isSelected = selectedId === node.id;
const isLinkSource = node.kind === 'lead' && node.leadId === linkSourceId;
const isPinned = node.fx !== null && node.fx !== undefined;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
opacity={dimmed ? 0.22 : 1}
onPointerDown={(event) => handleNodePointerDown(event, node)}
onPointerMove={handleNodePointerMove}
onPointerUp={(event) => handleNodePointerUp(event, node)}
onDoubleClick={() => handleNodeDoubleClick(node)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
className="cursor-pointer"
>
{(isSelected || isLinkSource) && (
<circle
r={node.radius + 6}
fill="none"
stroke={isLinkSource ? '#68a33f' : node.color}
strokeWidth={2.5}
strokeOpacity={0.55}
/>
)}
{node.kind === 'user' ? (
<rect
x={-node.radius}
y={-node.radius}
width={node.radius * 2}
height={node.radius * 2}
rx={6}
fill={SURFACE}
stroke={node.color}
strokeWidth={2.5}
/>
) : (
<circle r={node.radius} fill={node.color} stroke={SURFACE} strokeWidth={2} />
)}
{node.avatar && (
<image
href={node.avatar}
x={-(node.radius - 1)}
y={-(node.radius - 1)}
width={(node.radius - 1) * 2}
height={(node.radius - 1) * 2}
clipPath={`url(#clip-${node.id})`}
preserveAspectRatio="xMidYMid slice"
/>
)}
{node.kind === 'user' && !node.avatar && (
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={node.radius * 0.8}
fontWeight="800"
fill={node.color}
>
{node.label.charAt(0).toUpperCase()}
</text>
)}
{node.kind === 'lead' && !node.avatar && (
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={node.radius * 0.62}
fontWeight="800"
fill="#ffffff"
>
{leadInitials({
first_name: node.label.split(' ')[0] ?? '',
last_name: node.label.split(' ').slice(-1)[0] ?? '',
})}
</text>
)}
{isPinned && (
<circle cx={node.radius * 0.75} cy={-node.radius * 0.75} r={3} fill="#68a33f" stroke={SURFACE} strokeWidth={1} />
)}
<text
y={node.radius + 14}
textAnchor="middle"
fontSize={11}
fontWeight="700"
fill={LABEL_COLOR}
style={{ pointerEvents: 'none' }}
>
{node.label}
</text>
{node.sublabel && (
<text
y={node.radius + 26}
textAnchor="middle"
fontSize={9.5}
fontWeight="600"
fill={SUBLABEL_COLOR}
style={{ pointerEvents: 'none' }}
>
{node.sublabel}
</text>
)}
</g>
);
})}
</g>
</g>
</svg>
)}
</div>
</div>
{/* Detailpanel */}
<aside className="scrollbar-slim w-full shrink-0 overflow-y-auto rounded-2xl border border-ink-200 bg-surface p-5 shadow-card lg:w-80">
{!selectedNode && (
<div className="text-center">
<p className="text-sm font-bold text-ink-600">Netzwerk</p>
<p className="mt-2 text-xs leading-relaxed text-ink-400">
Klicke auf einen Knoten, um Details und Verbindungen zu sehen. Kreise sind Kontakte,
Quadrate sind Benutzer, die Kontakte angelegt haben.
</p>
<dl className="mt-5 grid grid-cols-2 gap-3 text-left">
<div className="rounded-xl bg-ink-50 px-3 py-2.5">
<dt className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">Knoten</dt>
<dd className="text-lg font-extrabold text-ink-900">{graph.nodes.length}</dd>
</div>
<div className="rounded-xl bg-ink-50 px-3 py-2.5">
<dt className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">Kanten</dt>
<dd className="text-lg font-extrabold text-ink-900">{graph.links.length}</dd>
</div>
</dl>
</div>
)}
{selectedLead && (
<div>
<div className="flex items-start gap-3">
<span
className="mt-1 h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: KIND_COLORS[selectedLead.kind] }}
/>
<div className="min-w-0">
<h3 className="truncate text-base text-ink-900">{leadFullName(selectedLead)}</h3>
<p className="truncate text-xs text-ink-400">
{[selectedLead.position, selectedLead.company].filter(Boolean).join(' · ') || 'Ohne Firma'}
</p>
</div>
</div>
<button
onClick={() => onEditLead(selectedLead)}
className="mt-4 w-full rounded-xl bg-brand-500 py-2.5 text-xs font-bold text-white transition hover:bg-brand-600"
>
Kontakt bearbeiten
</button>
<dl className="mt-4 space-y-1.5 text-xs">
{selectedLead.email && (
<div className="flex justify-between gap-2">
<dt className="text-ink-400">E-Mail</dt>
<dd className="truncate font-semibold text-ink-700">{selectedLead.email}</dd>
</div>
)}
{(selectedLead.phone || selectedLead.mobile) && (
<div className="flex justify-between gap-2">
<dt className="text-ink-400">Telefon</dt>
<dd className="truncate font-semibold text-ink-700">
{selectedLead.phone || selectedLead.mobile}
</dd>
</div>
)}
<div className="flex justify-between gap-2">
<dt className="text-ink-400">Angelegt von</dt>
<dd className="truncate font-semibold text-ink-700">{selectedLead.created_by_name || '—'}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-ink-400">Zuständig</dt>
<dd className="truncate font-semibold text-ink-700">{selectedLead.owner_name || '—'}</dd>
</div>
</dl>
<h4 className="mt-6 mb-2 text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Verbindungen ({selectedConnections.length})
</h4>
{selectedConnections.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-3 py-4 text-center text-xs text-ink-400">
Keine Verbindungen
</p>
) : (
<ul className="space-y-1.5">
{selectedConnections.map(({ connection, other }) => (
<li
key={connection.id}
className="flex items-center gap-2 rounded-xl bg-ink-50 px-3 py-2"
>
<button
onClick={() => setSelectedId(nodeId('lead', other!.id))}
className="min-w-0 flex-1 text-left"
>
<p className="truncate text-xs font-bold text-ink-800">{leadFullName(other!)}</p>
<p className="truncate text-[10px] font-semibold uppercase tracking-wider text-ink-400">
{connection.type}
</p>
</button>
<button
onClick={() => removeConnection(connection.id)}
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
aria-label="Verbindung entfernen"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
)}
{selectedNode?.kind === 'user' && (
<div>
<div className="flex items-start gap-3">
{selectedNode.avatar ? (
<img
src={selectedNode.avatar}
alt=""
className="h-10 w-10 shrink-0 rounded-xl object-cover"
/>
) : (
<span
className="mt-1 h-3 w-3 shrink-0 rounded-sm"
style={{ backgroundColor: USER_COLOR }}
/>
)}
<div className="min-w-0">
<h3 className="truncate text-base text-ink-900">{selectedNode.label}</h3>
<p className="truncate text-xs text-ink-400">{selectedNode.sublabel}</p>
</div>
</div>
<h4 className="mt-6 mb-2 text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Angelegte Kontakte ({selectedUserLeads.length})
</h4>
<ul className="space-y-1.5">
{selectedUserLeads.map((lead) => (
<li key={lead.id}>
<button
onClick={() => setSelectedId(nodeId('lead', lead.id))}
className="w-full rounded-xl bg-ink-50 px-3 py-2 text-left transition hover:bg-brand-50"
>
<p className="truncate text-xs font-bold text-ink-800">{leadFullName(lead)}</p>
{lead.company && <p className="truncate text-[10px] text-ink-400">{lead.company}</p>}
</button>
</li>
))}
</ul>
</div>
)}
</aside>
</div>
);
};