Files
2026-09-14 16:55:29 +02:00

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>
);
};