360 lines
20 KiB
TypeScript
360 lines
20 KiB
TypeScript
|
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
|
import { Customer, Project, Expert } from '../types';
|
|
|
|
interface OrgChartProps {
|
|
customers: Customer[];
|
|
projects: Project[];
|
|
experts: Expert[];
|
|
}
|
|
|
|
type Mode = 'Customer' | 'Project';
|
|
|
|
export const OrgChart: React.FC<OrgChartProps> = ({ customers, projects, experts }) => {
|
|
const [mode, setMode] = useState<Mode>('Customer');
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
// Navigation State
|
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
const [zoom, setZoom] = useState(1);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
|
|
const getRelatedData = useMemo(() => {
|
|
if (!selectedId) return null;
|
|
if (mode === 'Customer') {
|
|
const customer = customers.find(c => c.id === selectedId);
|
|
if (!customer) return null;
|
|
const companyProjects = projects.filter(p =>
|
|
p.customer === customer.company ||
|
|
(p.notes && p.notes.includes(`KUNDE: ${customer.company}`))
|
|
);
|
|
const relatedExperts = experts.filter(e =>
|
|
companyProjects.some(p => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`)))
|
|
);
|
|
return { root: customer, contacts: customer.contacts || [], projects: companyProjects, experts: relatedExperts };
|
|
} else {
|
|
const project = projects.find(p => p.id === selectedId);
|
|
if (!project) return null;
|
|
const customerMatch = customers.find(c =>
|
|
c.company === project.customer ||
|
|
(project.notes && project.notes.includes(`KUNDE: ${c.company}`))
|
|
);
|
|
|
|
// Multi-Expert Support for Projects
|
|
const expertNames = (project.expert || '').split(', ').map(s => s.trim()).filter(Boolean);
|
|
const relatedExperts = experts.filter(e =>
|
|
expertNames.includes(e.name) ||
|
|
(project.notes && project.notes.includes(`EXPERTE: ${e.name}`))
|
|
);
|
|
|
|
return { root: project, customer: customerMatch, experts: relatedExperts, contacts: customerMatch?.contacts || [] };
|
|
}
|
|
}, [mode, selectedId, customers, projects, experts]);
|
|
|
|
// Native Wheel Listener for preventDefault support (necessary for Zoom override)
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const onWheel = (e: WheelEvent) => {
|
|
// Prevent page scrolling/zooming while interacting with the canvas
|
|
e.preventDefault();
|
|
|
|
if (e.ctrlKey) {
|
|
// ZOOM MODE
|
|
const zoomSpeed = 0.001;
|
|
setZoom(prevZoom => {
|
|
const newZoom = prevZoom - e.deltaY * zoomSpeed;
|
|
return Math.min(Math.max(newZoom, 0.2), 2.5);
|
|
});
|
|
} else if (e.shiftKey) {
|
|
// HORIZONTAL SCROLL
|
|
setOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
|
|
} else {
|
|
// VERTICAL SCROLL (Default)
|
|
setOffset(prev => ({ ...prev, y: prev.y - e.deltaY }));
|
|
}
|
|
};
|
|
|
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
return () => canvas.removeEventListener('wheel', onWheel);
|
|
}, []);
|
|
|
|
// Mouse Handlers (Panning via Click & Drag)
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (e.button !== 0) return; // Only left click
|
|
setIsDragging(true);
|
|
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isDragging) return;
|
|
setOffset({
|
|
x: e.clientX - dragStart.x,
|
|
y: e.clientY - dragStart.y
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => setIsDragging(false);
|
|
|
|
const resetView = () => {
|
|
setOffset({ x: 0, y: 0 });
|
|
setZoom(1);
|
|
};
|
|
|
|
const handleSelect = (id: string) => {
|
|
setSelectedId(id);
|
|
resetView();
|
|
};
|
|
|
|
const ConnectionLines = () => {
|
|
const color = mode === 'Customer' ? '#22c55e' : '#3b82f6';
|
|
return (
|
|
<div className="absolute inset-0 w-full h-full pointer-events-none z-0">
|
|
<svg
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
className="w-full h-full overflow-visible"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<defs>
|
|
<filter id="glowLine" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="1" result="blur" />
|
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
|
</filter>
|
|
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
|
<stop offset="60%" stopColor={color} stopOpacity="0.8" />
|
|
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<g filter="url(#glowLine)">
|
|
<path
|
|
d="M 50 15 C 50 25, 15 25, 15 45"
|
|
stroke="url(#lineGrad)"
|
|
strokeWidth="1.5"
|
|
fill="none"
|
|
strokeDasharray="4 2"
|
|
className="animate-[flow_4s_linear_infinite]"
|
|
/>
|
|
<path
|
|
d="M 50 15 C 50 25, 85 25, 85 45"
|
|
stroke="url(#lineGrad)"
|
|
strokeWidth="1.5"
|
|
fill="none"
|
|
strokeDasharray="4 2"
|
|
className="animate-[flow_4s_linear_infinite]"
|
|
/>
|
|
<circle cx="50" cy="15" r="2" fill={color} className="animate-pulse shadow-lg" />
|
|
</g>
|
|
</svg>
|
|
<style>{`
|
|
@keyframes flow {
|
|
from { stroke-dashoffset: 40; }
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col gap-8 animate-in fade-in duration-500 overflow-hidden">
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0 px-2">
|
|
<div>
|
|
<h2 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight">Relations Engine</h2>
|
|
<p className="text-slate-500 font-medium">Das ICON Beziehungs-Netzwerk</p>
|
|
</div>
|
|
|
|
<div className="flex bg-slate-200 dark:bg-slate-900 p-1.5 rounded-[1.5rem] border-2 border-slate-300 dark:border-slate-800 shadow-inner">
|
|
<button
|
|
onClick={() => { setMode('Customer'); setSelectedId(null); resetView(); }}
|
|
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Customer' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
|
>
|
|
Kunden-Hub
|
|
</button>
|
|
<button
|
|
onClick={() => { setMode('Project'); setSelectedId(null); resetView(); }}
|
|
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Project' ? 'bg-blue-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
|
>
|
|
Projekt-Hub
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col lg:flex-row gap-8 overflow-hidden min-h-0">
|
|
{/* Sidebar */}
|
|
<div className="w-full lg:w-72 bg-white dark:bg-slate-900 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card shrink-0">
|
|
<div className="p-5 border-b-2 border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/30">
|
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
|
|
{mode === 'Customer' ? 'Daten: Kunden' : 'Daten: Projekte'}
|
|
</h3>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
|
|
{(mode === 'Customer' ? customers : projects).map((item: any) => (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleSelect(item.id)}
|
|
className={`p-4 cursor-pointer transition-all hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-4 ${selectedId === item.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : ''}`}
|
|
>
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-bold text-lg shrink-0 ${mode === 'Customer' ? 'bg-brand-500/10 text-brand-600' : 'bg-blue-500/10 text-blue-600'}`}>
|
|
{(item.company || item.title).charAt(0)}
|
|
</div>
|
|
<div className="truncate">
|
|
<p className="font-extrabold text-sm truncate dark:text-white">{item.company || item.title}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graph Area */}
|
|
<div
|
|
ref={canvasRef}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
className={`flex-1 bg-slate-100/30 dark:bg-slate-950/20 rounded-[4rem] border-4 border-dashed border-slate-200 dark:border-slate-800 relative overflow-hidden p-8 select-none transition-colors ${isDragging ? 'cursor-grabbing border-brand-500/50 bg-slate-100/50 dark:bg-slate-950/40' : 'cursor-grab hover:border-slate-300 dark:hover:border-slate-700'}`}
|
|
>
|
|
{selectedId && getRelatedData ? (
|
|
<div
|
|
className="w-full h-full relative"
|
|
style={{
|
|
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
|
transformOrigin: 'center center',
|
|
transition: isDragging ? 'none' : 'transform 0.15s cubic-bezier(0.2, 0, 0.2, 1)'
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 w-full h-[800px] min-w-[1200px]">
|
|
<ConnectionLines />
|
|
</div>
|
|
|
|
<div className="w-full relative z-10 py-10">
|
|
<div className="flex justify-center mb-48">
|
|
<div className={`p-8 rounded-[3rem] border-4 shadow-heavy flex flex-col items-center text-center w-72 animate-in zoom-in-95 duration-500 ${mode === 'Customer' ? 'bg-brand-500 text-white border-white dark:border-slate-800' : 'bg-blue-600 text-white border-white dark:border-slate-800'}`}>
|
|
<div className="w-20 h-20 bg-white/20 rounded-3xl flex items-center justify-center mb-4 text-4xl font-black">
|
|
{(getRelatedData.root as any).company?.charAt(0) || (getRelatedData.root as any).title?.charAt(0)}
|
|
</div>
|
|
<h3 className="text-2xl font-black tracking-tight leading-tight">{(getRelatedData.root as any).company || (getRelatedData.root as any).title}</h3>
|
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-80 mt-2">{mode === 'Customer' ? 'UNTERNEHMEN' : 'AKTIVES PROJEKT'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-row justify-between w-full max-w-7xl mx-auto items-start px-4 gap-12">
|
|
<div className="w-[380px] space-y-4 animate-in slide-in-from-left-24 duration-1000">
|
|
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
|
|
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
|
|
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Interne Kontakte' : 'Auftraggeber'}</h4>
|
|
</div>
|
|
|
|
{mode === 'Customer' ? (
|
|
<div className="space-y-4">
|
|
{getRelatedData.contacts.length > 0 ? getRelatedData.contacts.map((c: any) => (
|
|
<div key={c.id} className="bg-white dark:bg-slate-900 p-5 rounded-[2rem] border-2 border-slate-100 dark:border-slate-800 shadow-card flex items-center gap-4 hover:border-brand-500 transition-all">
|
|
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm font-black text-slate-400 uppercase">{c.name.charAt(0)}</div>
|
|
<div className="truncate text-left">
|
|
<p className="font-extrabold text-sm dark:text-white truncate">{c.name}</p>
|
|
<p className="text-[10px] text-brand-600 font-black uppercase tracking-wider">{c.role}</p>
|
|
</div>
|
|
</div>
|
|
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Daten</div>}
|
|
</div>
|
|
) : (
|
|
getRelatedData.customer && (
|
|
<div className="bg-white dark:bg-slate-900 p-8 rounded-[3rem] border-2 border-brand-500 shadow-xl-brand flex flex-col items-center text-center">
|
|
<div className="w-16 h-16 bg-brand-50 dark:bg-brand-900/20 rounded-2xl flex items-center justify-center mb-4 text-3xl font-black text-brand-600">
|
|
{getRelatedData.customer.company.charAt(0)}
|
|
</div>
|
|
<p className="font-black text-base dark:text-white">{getRelatedData.customer.company}</p>
|
|
<p className="text-[10px] font-bold text-slate-400 uppercase mt-2 tracking-widest">Zugeordneter Kunde</p>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-[380px] space-y-4 animate-in slide-in-from-right-24 duration-1000">
|
|
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
|
|
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
|
|
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Laufende Beauftragungen' : 'Expertise'}</h4>
|
|
</div>
|
|
|
|
{mode === 'Customer' ? (
|
|
<div className="space-y-6">
|
|
{getRelatedData.projects.length > 0 ? getRelatedData.projects.map((p: any) => (
|
|
<div key={p.id} className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-[2.5rem] border-2 border-blue-200 dark:border-blue-800 shadow-heavy text-left relative overflow-hidden group">
|
|
<div className="absolute top-0 right-0 w-16 h-16 bg-blue-500/5 -mr-8 -mt-8 rounded-full"></div>
|
|
<p className="font-black text-sm dark:text-blue-100 mb-5">{p.title}</p>
|
|
<div className="space-y-3">
|
|
<p className="text-[9px] font-black text-blue-400 uppercase tracking-widest ml-1">Eingesetzte Experten</p>
|
|
{experts.filter(e => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`))).map(e => (
|
|
<div key={e.id} className="bg-white dark:bg-slate-900 p-3 rounded-2xl border border-blue-100 dark:border-slate-800 flex items-center gap-3 shadow-sm">
|
|
<div className="w-8 h-8 rounded-xl bg-brand-500 text-white flex items-center justify-center text-[10px] font-black">{e.name.charAt(0)}</div>
|
|
<p className="font-bold text-xs dark:text-white truncate">{e.name}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Projekte</div>}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{getRelatedData.experts && getRelatedData.experts.length > 0 ? getRelatedData.experts.map((expert: Expert) => (
|
|
<div key={expert.id} className="bg-white dark:bg-slate-900 p-6 rounded-[2rem] border-2 border-blue-500 shadow-heavy flex items-center gap-5 hover:scale-[1.02] transition-transform">
|
|
<div className="relative shrink-0">
|
|
<div className="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden border-2 border-slate-200 dark:border-slate-700 shadow-md">
|
|
{expert.avatar ? <img src={expert.avatar} className="w-full h-full object-cover" /> : <span className="text-2xl font-black text-slate-300">{expert.name.charAt(0)}</span>}
|
|
</div>
|
|
<div className="absolute -bottom-1 -right-1 bg-brand-500 text-white p-1 rounded-lg shadow-md border-2 border-white dark:border-slate-900">
|
|
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
|
</div>
|
|
</div>
|
|
<div className="text-left truncate">
|
|
<p className="font-black text-base dark:text-white leading-tight truncate">{expert.name}</p>
|
|
<p className="text-[10px] font-black text-blue-600 uppercase mt-1 tracking-widest truncate">{expert.role}</p>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<div className="py-12 text-center bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Experten zugewiesen</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full w-full flex flex-col items-center justify-center animate-pulse">
|
|
<div className="w-24 h-24 bg-white dark:bg-slate-900 rounded-[2rem] flex items-center justify-center mb-8 text-slate-200 dark:text-slate-800 shadow-lg border-2 border-slate-100 dark:border-slate-800">
|
|
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
|
</div>
|
|
<h3 className="text-2xl font-black dark:text-white mb-2 uppercase tracking-tighter">Bereit zur Analyse</h3>
|
|
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest opacity-60 text-center">Wählen Sie ein Asset aus der Liste links aus.<br/>Rad: Scrollen | Strg + Rad: Zoom | Shift + Rad: Horiz. Scroll</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Canvas Controls */}
|
|
{selectedId && (
|
|
<div className="absolute bottom-8 right-8 flex flex-col gap-3 z-50">
|
|
<button
|
|
onClick={resetView}
|
|
title="Ansicht & Zoom zurücksetzen"
|
|
className="p-4 bg-white dark:bg-slate-800 text-slate-500 hover:text-brand-500 rounded-2xl shadow-heavy border border-slate-100 dark:border-slate-700 transition-all hover:scale-110 active:scale-95 flex items-center justify-center"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} 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 className="p-3 bg-slate-900/10 dark:bg-white/10 rounded-xl backdrop-blur-md flex flex-col items-center gap-1 min-w-[100px]">
|
|
<p className="text-[9px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">DRAG / SCROLL</p>
|
|
<p className="text-[10px] font-bold text-brand-600 dark:text-brand-400 tracking-widest">{Math.round(zoom * 100)}% ZOOM</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|