Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1002 lines
38 KiB
TypeScript
1002 lines
38 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 { STATUS_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: STATUS_COLORS[lead.status],
|
|
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 } | null>(null);
|
|
|
|
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,
|
|
};
|
|
(event.currentTarget as Element).setPointerCapture(event.pointerId);
|
|
};
|
|
|
|
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 handleBackgroundPointerUp = (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="Vergroessern"
|
|
>
|
|
<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 = loesen
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
ref={containerRef}
|
|
onPointerDown={handleBackgroundPointerDown}
|
|
onPointerMove={(event) => {
|
|
handleBackgroundPointerMove(event);
|
|
handleNodePointerMove(event);
|
|
}}
|
|
onPointerUp={handleBackgroundPointerUp}
|
|
onPointerLeave={handleBackgroundPointerUp}
|
|
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: STATUS_COLORS[selectedLead.status] }}
|
|
/>
|
|
<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">Zustaendig</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>
|
|
);
|
|
};
|