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>
1034 lines
39 KiB
TypeScript
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 ↔ 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 → 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>
|
|
);
|
|
};
|