So mit neuen UI Ideen und so
Dieser Commit ist enthalten in:
753
frontend/package-lock.json
generiert
753
frontend/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -10,12 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.88.0",
|
||||
"@react-three/fiber": "^8.15.0",
|
||||
"@skillmate/shared": "file:../shared",
|
||||
"@types/three": "^0.180.0",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"three": "^0.160.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
30
frontend/src/components/ErrorBoundary.tsx
Normale Datei
30
frontend/src/components/ErrorBoundary.tsx
Normale Datei
@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = { children: React.ReactNode }
|
||||
type State = { hasError: boolean; error?: any }
|
||||
|
||||
export default class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
componentDidCatch(error: any, info: any) {
|
||||
// Optionally log to a service
|
||||
console.error('UI ErrorBoundary:', error, info)
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2>Ein unerwarteter Fehler ist aufgetreten.</h2>
|
||||
<p>Bitte laden Sie die Seite neu oder versuchen Sie es später erneut.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
565
frontend/src/components/OfficeMap3D.tsx
Normale Datei
565
frontend/src/components/OfficeMap3D.tsx
Normale Datei
@ -0,0 +1,565 @@
|
||||
import React, { useState, useRef, Suspense } from 'react'
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber'
|
||||
import { OrbitControls, Text, Box, Plane, Line, PerspectiveCamera, Sphere, Billboard, RoundedBox } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Types
|
||||
interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
position: [number, number, number]
|
||||
size: [number, number, number]
|
||||
type: 'office' | 'meeting' | 'server' | 'kitchen' | 'restroom' | 'stairs' | 'elevator'
|
||||
occupants?: string[]
|
||||
}
|
||||
|
||||
interface FloorData {
|
||||
level: number
|
||||
name: string
|
||||
rooms: RoomData[]
|
||||
height: number
|
||||
}
|
||||
|
||||
// Dummy employee mapping
|
||||
const employeeNames: Record<string, string> = {
|
||||
'emp-001': 'Max Mustermann',
|
||||
'emp-002': 'Maria Schmidt',
|
||||
'emp-003': 'Thomas Weber',
|
||||
'emp-004': 'Sarah Fischer',
|
||||
'emp-005': 'Michael Bauer',
|
||||
'emp-006': 'Julia Wagner',
|
||||
'emp-007': 'Andreas Becker',
|
||||
'emp-008': 'Lisa Hoffmann',
|
||||
}
|
||||
|
||||
// Room type colors
|
||||
const roomColors = {
|
||||
office: '#3B82F6',
|
||||
meeting: '#10B981',
|
||||
server: '#6B7280',
|
||||
kitchen: '#F59E0B',
|
||||
restroom: '#9CA3AF',
|
||||
stairs: '#8B5CF6',
|
||||
elevator: '#EC4899',
|
||||
}
|
||||
|
||||
// Building data with 5 floors
|
||||
const buildingData: FloorData[] = [
|
||||
{
|
||||
level: 0,
|
||||
name: 'Erdgeschoss',
|
||||
height: 0,
|
||||
rooms: [
|
||||
{ id: 'EG-001', name: 'Empfang', position: [-3, 0.5, 2], size: [2, 1, 2], type: 'office' },
|
||||
{ id: 'EG-002', name: 'Kantine', position: [0, 0.5, 2], size: [3, 1, 2], type: 'kitchen' },
|
||||
{ id: 'EG-003', name: 'Meeting 1', position: [3, 0.5, 2], size: [2, 1, 2], type: 'meeting' },
|
||||
{ id: 'EG-S1', name: 'Treppe', position: [-4.5, 0.5, -2], size: [1, 1, 1], type: 'stairs' },
|
||||
{ id: 'EG-E1', name: 'Aufzug', position: [4.5, 0.5, -2], size: [1, 1, 1], type: 'elevator' },
|
||||
]
|
||||
},
|
||||
{
|
||||
level: 1,
|
||||
name: '1. OG - Verwaltung',
|
||||
height: 3,
|
||||
rooms: [
|
||||
{ id: '1-101', name: 'Büro 101', position: [-3, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-001'] },
|
||||
{ id: '1-102', name: 'Büro 102', position: [0, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-002'] },
|
||||
{ id: '1-103', name: 'Büro 103', position: [3, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-003'] },
|
||||
{ id: '1-Server', name: 'Server', position: [0, 3.5, -2], size: [2, 1, 1.5], type: 'server' },
|
||||
{ id: '1-S1', name: 'Treppe', position: [-4.5, 3.5, -2], size: [1, 1, 1], type: 'stairs' },
|
||||
{ id: '1-E1', name: 'Aufzug', position: [4.5, 3.5, -2], size: [1, 1, 1], type: 'elevator' },
|
||||
]
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
name: '2. OG - Kriminalpolizei',
|
||||
height: 6,
|
||||
rooms: [
|
||||
{ id: '2-201', name: 'Büro 201', position: [-3, 6.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-004'] },
|
||||
{ id: '2-202', name: 'Großraum', position: [0, 6.5, 2], size: [4, 1, 2], type: 'office', occupants: ['emp-005', 'emp-006'] },
|
||||
{ id: '2-203', name: 'Einsatzzentrale', position: [0, 6.5, -2], size: [3, 1, 1.5], type: 'meeting' },
|
||||
{ id: '2-S1', name: 'Treppe', position: [-4.5, 6.5, -2], size: [1, 1, 1], type: 'stairs' },
|
||||
{ id: '2-E1', name: 'Aufzug', position: [4.5, 6.5, -2], size: [1, 1, 1], type: 'elevator' },
|
||||
]
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
name: '3. OG - Staatsschutz',
|
||||
height: 9,
|
||||
rooms: [
|
||||
{ id: '3-301', name: 'Büro 301', position: [-3, 9.5, 2], size: [2, 1, 2], type: 'office' },
|
||||
{ id: '3-302', name: 'Büro 302', position: [0, 9.5, 2], size: [2, 1, 2], type: 'office' },
|
||||
{ id: '3-303', name: 'Abhörsicher', position: [3, 9.5, 2], size: [2, 1, 2], type: 'meeting' },
|
||||
{ id: '3-Server', name: 'Server', position: [0, 9.5, -2], size: [2, 1, 1.5], type: 'server' },
|
||||
{ id: '3-S1', name: 'Treppe', position: [-4.5, 9.5, -2], size: [1, 1, 1], type: 'stairs' },
|
||||
{ id: '3-E1', name: 'Aufzug', position: [4.5, 9.5, -2], size: [1, 1, 1], type: 'elevator' },
|
||||
]
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
name: '4. OG - Cybercrime & IT',
|
||||
height: 12,
|
||||
rooms: [
|
||||
{ id: '4-401', name: 'Cybercrime Team', position: [-2, 12.5, 2], size: [4, 1, 2], type: 'office', occupants: ['emp-007', 'emp-008'] },
|
||||
{ id: '4-402', name: 'IT-Forensik', position: [3, 12.5, 2], size: [2, 1, 2], type: 'office' },
|
||||
{ id: '4-Server', name: 'Hauptserver', position: [0, 12.5, -2], size: [3, 1, 1.5], type: 'server' },
|
||||
{ id: '4-S1', name: 'Treppe', position: [-4.5, 12.5, -2], size: [1, 1, 1], type: 'stairs' },
|
||||
{ id: '4-E1', name: 'Aufzug', position: [4.5, 12.5, -2], size: [1, 1, 1], type: 'elevator' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// Room Component
|
||||
function Room({ room, isSelected, isStart, isEnd, onSelect }: {
|
||||
room: RoomData
|
||||
isSelected: boolean
|
||||
isStart?: boolean
|
||||
isEnd?: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
// Gentle floating animation for selected room
|
||||
if (isSelected) {
|
||||
meshRef.current.position.y = room.position[1] + Math.sin(state.clock.elapsedTime * 2) * 0.05
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const color = isStart ? '#10B981' : isEnd ? '#EF4444' : isSelected ? '#FCD34D' : (hovered ? '#FCA5A5' : roomColors[room.type])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Box
|
||||
ref={meshRef}
|
||||
args={room.size}
|
||||
position={room.position}
|
||||
onClick={onSelect}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={isStart || isEnd ? 0.9 : 0.7}
|
||||
emissive={isStart ? '#10B981' : isEnd ? '#EF4444' : isSelected ? '#FCD34D' : '#000000'}
|
||||
emissiveIntensity={isStart || isEnd ? 0.3 : isSelected ? 0.2 : 0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Room label with billboard and background */}
|
||||
<Billboard
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.2, room.position[2]]}
|
||||
>
|
||||
<RoundedBox args={[room.name.length * 0.08, 0.25, 0.01]} radius={0.02}>
|
||||
<meshBasicMaterial color="#1F2937" opacity={0.9} transparent />
|
||||
</RoundedBox>
|
||||
<Text
|
||||
fontSize={0.15}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
position={[0, 0, 0.01]}
|
||||
>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Billboard>
|
||||
|
||||
{/* Occupant count with billboard and background */}
|
||||
{room.occupants && room.occupants.length > 0 && !isStart && !isEnd && (
|
||||
<Billboard
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
position={[room.position[0], room.position[1] - room.size[1]/2 - 0.2, room.position[2]]}
|
||||
>
|
||||
<RoundedBox args={[1.0, 0.2, 0.01]} radius={0.02}>
|
||||
<meshBasicMaterial color="#1F2937" opacity={0.8} transparent />
|
||||
</RoundedBox>
|
||||
<Text
|
||||
fontSize={0.12}
|
||||
color="#FFD700"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
position={[0, 0, 0.01]}
|
||||
>
|
||||
{room.occupants.length} Person(en)
|
||||
</Text>
|
||||
</Billboard>
|
||||
)}
|
||||
|
||||
{/* Start/End markers with billboard and background */}
|
||||
{isStart && (
|
||||
<Billboard
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.5, room.position[2]]}
|
||||
>
|
||||
<RoundedBox args={[0.8, 0.35, 0.01]} radius={0.05}>
|
||||
<meshBasicMaterial color="#10B981" opacity={0.9} transparent />
|
||||
</RoundedBox>
|
||||
<Text
|
||||
fontSize={0.25}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
position={[0, 0, 0.01]}
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#065F46"
|
||||
>
|
||||
START
|
||||
</Text>
|
||||
</Billboard>
|
||||
)}
|
||||
{isEnd && (
|
||||
<Billboard
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.5, room.position[2]]}
|
||||
>
|
||||
<RoundedBox args={[0.6, 0.35, 0.01]} radius={0.05}>
|
||||
<meshBasicMaterial color="#EF4444" opacity={0.9} transparent />
|
||||
</RoundedBox>
|
||||
<Text
|
||||
fontSize={0.25}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
position={[0, 0, 0.01]}
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#7F1D1D"
|
||||
>
|
||||
ZIEL
|
||||
</Text>
|
||||
</Billboard>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
// Floor Component
|
||||
function Floor({ floor, visible, opacity, selectedRoom, startRoom, endRoom, onSelectRoom }: {
|
||||
floor: FloorData
|
||||
visible: boolean
|
||||
opacity: number
|
||||
selectedRoom: string | null
|
||||
startRoom: string | null
|
||||
endRoom: string | null
|
||||
onSelectRoom: (roomId: string) => void
|
||||
}) {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor plate */}
|
||||
<Plane
|
||||
args={[12, 8]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, floor.height, 0]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#E5E7EB"
|
||||
transparent
|
||||
opacity={opacity * 0.3}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</Plane>
|
||||
|
||||
{/* Floor label with billboard and background */}
|
||||
<Billboard
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
position={[-7, floor.height + 0.5, 5]}
|
||||
>
|
||||
<RoundedBox args={[floor.name.length * 0.11, 0.4, 0.02]} radius={0.05}>
|
||||
<meshBasicMaterial color="#111827" opacity={0.95} transparent />
|
||||
</RoundedBox>
|
||||
<Text
|
||||
fontSize={0.25}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
position={[0, 0, 0.02]}
|
||||
outlineWidth={0.015}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{floor.name}
|
||||
</Text>
|
||||
</Billboard>
|
||||
|
||||
{/* Rooms */}
|
||||
{floor.rooms.map(room => (
|
||||
<Room
|
||||
key={room.id}
|
||||
room={room}
|
||||
isSelected={selectedRoom === room.id}
|
||||
isStart={startRoom === room.id}
|
||||
isEnd={endRoom === room.id}
|
||||
onSelect={() => onSelectRoom(room.id)}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
// Camera Controller Component - removed to allow free orbit controls
|
||||
|
||||
// Main 3D Office Map Component
|
||||
interface OfficeMap3DProps {
|
||||
targetEmployeeId?: string
|
||||
targetRoom?: string
|
||||
currentUserRoom?: string // Room of logged-in user
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function OfficeMap3D({ targetEmployeeId, targetRoom, currentUserRoom, onClose }: OfficeMap3DProps) {
|
||||
const [selectedFloor, setSelectedFloor] = useState(0)
|
||||
const [selectedRoom, setSelectedRoom] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'all' | 'single'>('all')
|
||||
const [startRoom, setStartRoom] = useState<string | null>(currentUserRoom || null)
|
||||
const [endRoom, setEndRoom] = useState<string | null>(null)
|
||||
|
||||
// Find target room/floor
|
||||
React.useEffect(() => {
|
||||
if (targetEmployeeId || targetRoom) {
|
||||
for (const floor of buildingData) {
|
||||
for (const room of floor.rooms) {
|
||||
if (targetRoom === room.id || room.occupants?.includes(targetEmployeeId || '')) {
|
||||
setEndRoom(room.id)
|
||||
setSelectedFloor(floor.level)
|
||||
setSelectedRoom(room.id)
|
||||
setViewMode('single')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [targetEmployeeId, targetRoom])
|
||||
|
||||
const handleSelectFloor = (level: number) => {
|
||||
setSelectedFloor(level)
|
||||
setViewMode('single')
|
||||
}
|
||||
|
||||
const getRoomDetails = () => {
|
||||
if (!selectedRoom) return null
|
||||
for (const floor of buildingData) {
|
||||
const room = floor.rooms.find(r => r.id === selectedRoom)
|
||||
if (room) {
|
||||
return {
|
||||
...room,
|
||||
floorName: floor.name,
|
||||
occupantNames: room.occupants?.map(id => employeeNames[id] || id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const roomDetails = getRoomDetails()
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg flex flex-col" style={{ height: '700px' }}>
|
||||
{/* Top bar with horizontal floor controls */}
|
||||
<div className="p-4 pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
{/* Floor buttons - horizontal */}
|
||||
<button
|
||||
onClick={() => setViewMode('all')}
|
||||
className={`px-3 py-1 rounded ${viewMode === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
Alle Etagen
|
||||
</button>
|
||||
{buildingData.map(floor => (
|
||||
<button
|
||||
key={floor.level}
|
||||
onClick={() => handleSelectFloor(floor.level)}
|
||||
className={`px-3 py-1 rounded ${
|
||||
viewMode === 'single' && selectedFloor === floor.level
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{floor.level === 0 ? 'EG' : `${floor.level}. OG`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-white text-sm">
|
||||
Maus zum Drehen/Zoomen | Klick für Details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content - room details left, 3D canvas right */}
|
||||
<div className="flex-1 flex gap-4 px-4 pb-4" style={{ minHeight: 0 }}>
|
||||
{/* Left side - Room details */}
|
||||
<div className="flex-shrink-0" style={{ width: '250px' }}>
|
||||
{roomDetails ? (
|
||||
<div className="p-3 bg-gray-800 rounded text-white h-full">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold">{roomDetails.name}</h4>
|
||||
<span className={`px-2 py-1 rounded text-xs bg-opacity-20 ${
|
||||
roomDetails.type === 'office' ? 'bg-blue-500 text-blue-300' :
|
||||
roomDetails.type === 'meeting' ? 'bg-green-500 text-green-300' :
|
||||
roomDetails.type === 'server' ? 'bg-gray-500 text-gray-300' :
|
||||
'bg-yellow-500 text-yellow-300'
|
||||
}`}>
|
||||
{roomDetails.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{roomDetails.floorName}</p>
|
||||
{roomDetails.occupantNames && roomDetails.occupantNames.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-400">Mitarbeiter:</p>
|
||||
{roomDetails.occupantNames.map((name, i) => (
|
||||
<p key={i} className="text-sm">• {name}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-gray-800 rounded text-gray-400 text-sm h-full">
|
||||
Klicken Sie auf einen Raum für Details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - 3D Canvas */}
|
||||
<div className="flex-1 bg-gray-800 rounded overflow-hidden">
|
||||
<Canvas shadows camera={{ position: [20, 20, 20], fov: 50 }}>
|
||||
<Suspense fallback={null}>
|
||||
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 20, 10]} intensity={1} castShadow />
|
||||
<directionalLight position={[0, 10, 5]} intensity={0.5} castShadow />
|
||||
|
||||
{/* Grid */}
|
||||
<gridHelper args={[20, 20]} position={[0, 0, 0]} />
|
||||
|
||||
{/* Building floors */}
|
||||
{buildingData.map(floor => (
|
||||
<Floor
|
||||
key={floor.level}
|
||||
floor={floor}
|
||||
visible={viewMode === 'all' || floor.level === selectedFloor}
|
||||
opacity={viewMode === 'all' ? (floor.level === selectedFloor ? 1 : 0.3) : 1}
|
||||
selectedRoom={selectedRoom}
|
||||
startRoom={startRoom}
|
||||
endRoom={endRoom}
|
||||
onSelectRoom={setSelectedRoom}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connecting elements (stairs/elevator shafts) */}
|
||||
{viewMode === 'all' && (
|
||||
<group>
|
||||
{/* Elevator shaft */}
|
||||
<Line
|
||||
points={[[4.5, 0, -2], [4.5, 13, -2]]}
|
||||
color="#EC4899"
|
||||
lineWidth={3}
|
||||
/>
|
||||
{/* Stair shaft */}
|
||||
<Line
|
||||
points={[[-4.5, 0, -2], [-4.5, 13, -2]]}
|
||||
color="#8B5CF6"
|
||||
lineWidth={3}
|
||||
/>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{/* Path visualization between start and end */}
|
||||
{startRoom && endRoom && (() => {
|
||||
let startPos: [number, number, number] | null = null
|
||||
let endPos: [number, number, number] | null = null
|
||||
let startFloor = -1
|
||||
let endFloor = -1
|
||||
|
||||
// Find positions
|
||||
for (const floor of buildingData) {
|
||||
for (const room of floor.rooms) {
|
||||
if (room.id === startRoom) {
|
||||
startPos = room.position
|
||||
startFloor = floor.level
|
||||
}
|
||||
if (room.id === endRoom) {
|
||||
endPos = room.position
|
||||
endFloor = floor.level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startPos && endPos) {
|
||||
const points: [number, number, number][] = []
|
||||
|
||||
if (startFloor === endFloor) {
|
||||
// Same floor - direct path
|
||||
points.push(startPos)
|
||||
points.push(endPos)
|
||||
} else {
|
||||
// Different floors - path through elevator
|
||||
points.push(startPos)
|
||||
// Go to elevator on start floor
|
||||
points.push([4.5, startPos[1], -2])
|
||||
// Move to destination floor
|
||||
points.push([4.5, endPos[1], -2])
|
||||
// Go to destination
|
||||
points.push(endPos)
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Line
|
||||
points={points}
|
||||
color="#FCD34D"
|
||||
lineWidth={4}
|
||||
dashed
|
||||
dashScale={5}
|
||||
dashSize={0.5}
|
||||
gapSize={0.5}
|
||||
/>
|
||||
{/* Animated sphere along path */}
|
||||
<Sphere args={[0.2]} position={startPos}>
|
||||
<meshBasicMaterial color="#FCD34D" />
|
||||
</Sphere>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
<OrbitControls
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
minDistance={3}
|
||||
maxDistance={50}
|
||||
maxPolarAngle={Math.PI / 2.5}
|
||||
zoomToCursor={true}
|
||||
panSpeed={0.8}
|
||||
rotateSpeed={0.8}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/OfficeMapModal.tsx
Normale Datei
98
frontend/src/components/OfficeMapModal.tsx
Normale Datei
@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import OfficeMap3D from './OfficeMap3D'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface OfficeMapModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
targetEmployeeId?: string
|
||||
targetRoom?: string
|
||||
currentUserRoom?: string
|
||||
employeeName?: string
|
||||
}
|
||||
|
||||
export default function OfficeMapModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
targetEmployeeId,
|
||||
targetRoom,
|
||||
currentUserRoom,
|
||||
employeeName
|
||||
}: OfficeMapModalProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="bg-gray-900 rounded-lg shadow-xl w-full mx-auto" style={{ maxWidth: '1400px' }}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Wegbeschreibung zu {employeeName || 'Mitarbeiter'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<span className="text-green-400">● Start:</span> Ihr Büro
|
||||
{' | '}
|
||||
<span className="text-red-400">● Ziel:</span> {targetRoom || 'Zielbüro'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map Content */}
|
||||
<OfficeMap3D
|
||||
targetEmployeeId={targetEmployeeId}
|
||||
targetRoom={targetRoom}
|
||||
currentUserRoom={currentUserRoom}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Footer with legend */}
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<span className="text-gray-300">Ihr Standort</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
||||
<span className="text-gray-300">Ziel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<span className="text-gray-300">Büro</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-purple-500 rounded"></div>
|
||||
<span className="text-gray-300">Treppe/Aufzug</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -3,13 +3,17 @@ import { useState, useEffect } from 'react'
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
// Dynamische Hierarchie für Darstellung der Nutzer-Skills
|
||||
import SkillLevelBar from '../components/SkillLevelBar'
|
||||
import { employeeApi } from '../services/api'
|
||||
import OfficeMapModal from '../components/OfficeMapModal'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export default function EmployeeDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const [employee, setEmployee] = useState<Employee | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [showOfficeMap, setShowOfficeMap] = useState(false)
|
||||
const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
|
||||
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
|
||||
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
|
||||
@ -141,10 +145,17 @@ export default function EmployeeDetail() {
|
||||
<div>
|
||||
<span className="text-tertiary">Büro:</span>
|
||||
<p className="text-secondary">{employee.office}</p>
|
||||
<button
|
||||
onClick={() => setShowOfficeMap(!showOfficeMap)}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
{showOfficeMap ? 'Karte ausblenden' : 'Büro zeigen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@ -227,6 +238,16 @@ export default function EmployeeDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Office Map Modal */}
|
||||
<OfficeMapModal
|
||||
isOpen={showOfficeMap}
|
||||
onClose={() => setShowOfficeMap(false)}
|
||||
targetEmployeeId={employee?.id}
|
||||
targetRoom={employee?.office || undefined}
|
||||
currentUserRoom="1-101" // This should come from logged-in user data
|
||||
employeeName={employee ? `${employee.firstName} ${employee.lastName}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,137 +4,253 @@ import { SearchIcon } from '../components/icons'
|
||||
import EmployeeCard from '../components/EmployeeCard'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { employeeApi } from '../services/api'
|
||||
// Import the skill hierarchy - we'll load it dynamically in useEffect
|
||||
|
||||
type SkillWithStats = {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
subcategory: string
|
||||
userCount: number
|
||||
levelDistribution: {
|
||||
beginner: number
|
||||
intermediate: number
|
||||
expert: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function SkillSearch() {
|
||||
const navigate = useNavigate()
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedSubCategory, setSelectedSubCategory] = useState('')
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([])
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set())
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set())
|
||||
const [freeSearchTerm, setFreeSearchTerm] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<Employee[]>([])
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [filteredEmployees, setFilteredEmployees] = useState<Employee[]>([])
|
||||
const [allEmployees, setAllEmployees] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
|
||||
const [allSkillsWithStats, setAllSkillsWithStats] = useState<SkillWithStats[]>([])
|
||||
const [searchSuggestions, setSearchSuggestions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
// Try to load static skill hierarchy
|
||||
let SKILL_HIERARCHY: any[] = []
|
||||
try {
|
||||
const skillModule = await import('../../../shared/skills.js')
|
||||
SKILL_HIERARCHY = skillModule.SKILL_HIERARCHY || []
|
||||
} catch {
|
||||
console.log('Could not load static skill hierarchy')
|
||||
}
|
||||
|
||||
// Load hierarchy from API
|
||||
const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data?.success) setHierarchy(data.data || [])
|
||||
if (data?.success) {
|
||||
// Use API data if available, otherwise fallback to static hierarchy
|
||||
const apiHierarchy = data.data || []
|
||||
const combinedHierarchy = apiHierarchy.length > 0 ? apiHierarchy : SKILL_HIERARCHY
|
||||
setHierarchy(combinedHierarchy)
|
||||
|
||||
// Calculate skill stats from employees
|
||||
const empRes = await employeeApi.getAll()
|
||||
setAllEmployees(empRes) // Store all employees for filtering
|
||||
const skillStats: Record<string, SkillWithStats> = {}
|
||||
|
||||
// Process hierarchy to get all skills - use combined hierarchy
|
||||
const hierarchyData = combinedHierarchy
|
||||
hierarchyData.forEach((cat: any) => {
|
||||
cat.subcategories?.forEach((sub: any) => {
|
||||
sub.skills?.forEach((skill: any) => {
|
||||
skillStats[skill.id] = {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
category: cat.id, // Use category ID to match selectedCategories
|
||||
subcategory: sub.name,
|
||||
userCount: 0,
|
||||
levelDistribution: { beginner: 0, intermediate: 0, expert: 0 }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Count employees and levels
|
||||
empRes.forEach((emp: any) => {
|
||||
emp.skills?.forEach((skill: any) => {
|
||||
if (skillStats[skill.id]) {
|
||||
skillStats[skill.id].userCount++
|
||||
const level = parseInt(skill.level) || 0
|
||||
if (level >= 1 && level <= 3) {
|
||||
skillStats[skill.id].levelDistribution.beginner++
|
||||
} else if (level >= 4 && level <= 6) {
|
||||
skillStats[skill.id].levelDistribution.intermediate++
|
||||
} else if (level >= 7 && level <= 10) {
|
||||
skillStats[skill.id].levelDistribution.expert++
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setAllSkillsWithStats(Object.values(skillStats))
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleCategoryChange = (categoryId: string) => {
|
||||
setSelectedCategory(categoryId)
|
||||
setSelectedSubCategory('')
|
||||
setSelectedSkills([])
|
||||
// Live search suggestions
|
||||
useEffect(() => {
|
||||
if (freeSearchTerm.length > 1) {
|
||||
const suggestions: string[] = []
|
||||
|
||||
// Search in skills
|
||||
allSkillsWithStats.forEach(skill => {
|
||||
if (skill.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||
suggestions.push(skill.name)
|
||||
}
|
||||
})
|
||||
|
||||
// Search in categories
|
||||
hierarchy.forEach(cat => {
|
||||
if (cat.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||
suggestions.push(cat.name)
|
||||
}
|
||||
cat.subcategories?.forEach(sub => {
|
||||
if (sub.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||
suggestions.push(sub.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setSearchSuggestions(suggestions.slice(0, 5))
|
||||
} else {
|
||||
setSearchSuggestions([])
|
||||
}
|
||||
}, [freeSearchTerm, allSkillsWithStats, hierarchy])
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
const newSelection = new Set(selectedCategories)
|
||||
if (newSelection.has(categoryId)) {
|
||||
newSelection.delete(categoryId)
|
||||
if (newSelection.size === 0) {
|
||||
setSelectedSkills(new Set())
|
||||
setFilteredEmployees([])
|
||||
}
|
||||
} else {
|
||||
newSelection.add(categoryId)
|
||||
}
|
||||
setSelectedCategories(newSelection)
|
||||
}
|
||||
|
||||
const handleSubCategoryChange = (subCategoryId: string) => {
|
||||
setSelectedSubCategory(subCategoryId)
|
||||
setSelectedSkills([])
|
||||
}
|
||||
|
||||
const handleSkillToggle = (skillId: string) => {
|
||||
setSelectedSkills(prev =>
|
||||
prev.includes(skillId)
|
||||
? prev.filter(s => s !== skillId)
|
||||
: [...prev, skillId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
setLoading(true)
|
||||
setHasSearched(true)
|
||||
// Toggle skill selection and filter employees
|
||||
const handleSkillClick = (skillId: string) => {
|
||||
const newSelection = new Set(selectedSkills)
|
||||
|
||||
try {
|
||||
// Get all employees first
|
||||
const allEmployees = await employeeApi.getAll()
|
||||
if (newSelection.has(skillId)) {
|
||||
newSelection.delete(skillId)
|
||||
} else {
|
||||
newSelection.add(skillId)
|
||||
}
|
||||
|
||||
setSelectedSkills(newSelection)
|
||||
|
||||
// Filter employees who have ANY of the selected skills
|
||||
if (newSelection.size === 0) {
|
||||
setFilteredEmployees([])
|
||||
} else {
|
||||
const filtered = allEmployees.filter(employee =>
|
||||
Array.from(newSelection).some(selectedId =>
|
||||
employee.skills?.some((skill: any) => skill.id === selectedId)
|
||||
)
|
||||
).map(emp => {
|
||||
// Find highest skill level among selected skills
|
||||
const selectedSkillLevels = Array.from(newSelection)
|
||||
.map(selectedId => emp.skills?.find((s: any) => s.id === selectedId)?.level || 0)
|
||||
.map(level => parseInt(level) || 0)
|
||||
const maxLevel = Math.max(...selectedSkillLevels, 0)
|
||||
|
||||
return {
|
||||
...emp,
|
||||
selectedSkillLevel: maxLevel,
|
||||
matchedSkillsCount: selectedSkillLevels.filter(l => l > 0).length
|
||||
}
|
||||
}).sort((a: any, b: any) => {
|
||||
// Sort by number of matched skills, then by highest level
|
||||
if (a.matchedSkillsCount !== b.matchedSkillsCount) {
|
||||
return b.matchedSkillsCount - a.matchedSkillsCount
|
||||
}
|
||||
return b.selectedSkillLevel - a.selectedSkillLevel
|
||||
})
|
||||
|
||||
// Filter based on search criteria
|
||||
let results = [...allEmployees]
|
||||
|
||||
// Filter by free search term
|
||||
if (freeSearchTerm) {
|
||||
const searchLower = freeSearchTerm.toLowerCase()
|
||||
results = results.filter(employee => {
|
||||
// Search in name, department, position
|
||||
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
|
||||
employee.department.toLowerCase().includes(searchLower) ||
|
||||
employee.position.toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search in skills
|
||||
if (employee.skills?.some((skill: any) => skill.name.toLowerCase().includes(searchLower))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search in languages
|
||||
if (employee.languages?.some((lang: any) => {
|
||||
if (!lang) return false
|
||||
return lang.language.toLowerCase().includes(searchLower)
|
||||
})) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search in specializations
|
||||
if (employee.specializations?.some((spec: any) => spec.toLowerCase().includes(searchLower))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by selected skills (IDs)
|
||||
if (selectedSkills.length > 0) {
|
||||
results = results.filter(employee => {
|
||||
return selectedSkills.some(skillId => {
|
||||
// Check in skills array by ID
|
||||
if (employee.skills?.some((skill: any) => skill.id === skillId)) {
|
||||
return true
|
||||
}
|
||||
// Ignore languages/specializations here (catalog-driven search)
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setSearchResults(results)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setFilteredEmployees(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Live search as user types
|
||||
useEffect(() => {
|
||||
if (freeSearchTerm.length > 2) {
|
||||
const searchLower = freeSearchTerm.toLowerCase()
|
||||
const results = allEmployees.filter(employee => {
|
||||
// Search in name, department, position
|
||||
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
|
||||
employee.department?.toLowerCase().includes(searchLower) ||
|
||||
employee.position?.toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search in skills
|
||||
if (employee.skills?.some((skill: any) => skill.name?.toLowerCase().includes(searchLower))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search in specializations
|
||||
if (employee.specializations?.some((spec: any) => spec?.toLowerCase().includes(searchLower))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
setFilteredEmployees(results)
|
||||
} else if (freeSearchTerm.length === 0 && selectedSkills.size > 0) {
|
||||
// If search is cleared, reapply skill filters
|
||||
const filtered = allEmployees.filter(employee =>
|
||||
Array.from(selectedSkills).some(selectedId =>
|
||||
employee.skills?.some((skill: any) => skill.id === selectedId)
|
||||
)
|
||||
)
|
||||
setFilteredEmployees(filtered)
|
||||
}
|
||||
}, [freeSearchTerm, allEmployees])
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedCategory('')
|
||||
setSelectedSubCategory('')
|
||||
setSelectedSkills([])
|
||||
setSelectedCategories(new Set())
|
||||
setSelectedSkills(new Set())
|
||||
setFreeSearchTerm('')
|
||||
setSearchResults([])
|
||||
setHasSearched(false)
|
||||
setFilteredEmployees([])
|
||||
setSearchSuggestions([])
|
||||
}
|
||||
|
||||
const getSubCategories = () => {
|
||||
if (!selectedCategory) return []
|
||||
const category = hierarchy.find(cat => cat.id === selectedCategory)
|
||||
return category?.subcategories || []
|
||||
// Category colors and icons
|
||||
const categoryConfig: Record<string, { icon: string; color: string }> = {
|
||||
'communication': { icon: '💬', color: 'bg-blue-100 text-blue-800 border-blue-300' },
|
||||
'technical': { icon: '💻', color: 'bg-green-100 text-green-800 border-green-300' },
|
||||
'operational': { icon: '🎯', color: 'bg-orange-100 text-orange-800 border-orange-300' },
|
||||
'analytical': { icon: '📊', color: 'bg-purple-100 text-purple-800 border-purple-300' },
|
||||
'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
}
|
||||
|
||||
const getSkills = () => {
|
||||
if (!selectedCategory || !selectedSubCategory) return []
|
||||
const category = hierarchy.find(cat => cat.id === selectedCategory)
|
||||
const subCategory = category?.subcategories.find(sub => sub.id === selectedSubCategory)
|
||||
return subCategory?.skills || []
|
||||
const getCategoryConfig = (categoryName: string) => {
|
||||
const key = categoryName.toLowerCase().replace(/[^a-z]/g, '')
|
||||
return categoryConfig[key] || { icon: '📁', color: 'bg-gray-100 text-gray-800 border-gray-300' }
|
||||
}
|
||||
|
||||
const getFilteredSkills = () => {
|
||||
if (selectedCategories.size === 0) return []
|
||||
return allSkillsWithStats.filter(skill =>
|
||||
selectedCategories.has(skill.category)
|
||||
).sort((a, b) => b.userCount - a.userCount)
|
||||
}
|
||||
|
||||
const getSkillNameById = (skillId: string) => {
|
||||
@ -150,21 +266,21 @@ export default function SkillSearch() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
|
||||
Skill-Suche
|
||||
Skill-Explorer
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
|
||||
Suchkriterien
|
||||
Filter
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Freie Suche */}
|
||||
{/* Freie Suche mit Live-Vorschlägen */}
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Freie Suche
|
||||
Intelligente Suche
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@ -173,147 +289,308 @@ export default function SkillSearch() {
|
||||
onChange={(e) => setFreeSearchTerm(e.target.value)}
|
||||
placeholder="Name, Skill, Abteilung..."
|
||||
className="input-field w-full pl-10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-placeholder" />
|
||||
|
||||
{/* Live-Vorschläge */}
|
||||
{searchSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-border-default rounded-lg shadow-lg">
|
||||
{searchSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setFreeSearchTerm(suggestion)
|
||||
setSearchSuggestions([])
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 hover:bg-bg-accent text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorie-Auswahl */}
|
||||
{/* Kategorie Filter-Bubbles */}
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Skill-Kategorie
|
||||
Kategorien (Mehrfachauswahl)
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{hierarchy.map((category) => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hierarchy.map((category) => {
|
||||
const config = getCategoryConfig(category.name)
|
||||
const isSelected = selectedCategories.has(category.id) // Use ID for checking
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => toggleCategory(category.id)} // Pass ID instead of name
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all flex items-center gap-1 border-2 ${
|
||||
isSelected
|
||||
? config.color + ' shadow-md scale-105'
|
||||
: 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span>{config.icon}</span>
|
||||
<span>{category.name}</span>
|
||||
{isSelected && <span className="ml-1">✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unterkategorie-Auswahl */}
|
||||
{selectedCategory && (
|
||||
{/* Remove old skill grid from here - will be in middle column */}
|
||||
{false && (
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Unterkategorie
|
||||
Verfügbare Skills ({getFilteredSkills().length})
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubCategory}
|
||||
onChange={(e) => handleSubCategoryChange(e.target.value)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="">Alle Unterkategorien</option>
|
||||
{getSubCategories().map((subCategory) => (
|
||||
<option key={subCategory.id} value={subCategory.id}>{subCategory.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill-Auswahl */}
|
||||
{selectedSubCategory && (
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Skills auswählen
|
||||
</label>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto border border-border-default rounded-card p-3">
|
||||
{getSkills().map((skill) => (
|
||||
<label key={skill.id} className="flex items-center space-x-2 cursor-pointer hover:bg-bg-accent p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSkills.includes(skill.id)}
|
||||
onChange={() => handleSkillToggle(skill.id)}
|
||||
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
<span className="text-body text-secondary">{skill.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="border border-border-default rounded-card p-3 max-h-96 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{getFilteredSkills().map((skill) => {
|
||||
const isSelected = selectedSkills.includes(skill.id)
|
||||
const dist = skill.levelDistribution
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleSkillToggle(skill.id)}
|
||||
className={`p-3 rounded-lg border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? 'bg-blue-50 border-blue-400 dark:bg-blue-900/20 dark:border-blue-500'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-primary">{skill.name}</span>
|
||||
<span className="ml-2 text-xs text-tertiary">({skill.subcategory})</span>
|
||||
</div>
|
||||
{isSelected && <span className="text-blue-600 text-lg">✓</span>}
|
||||
</div>
|
||||
|
||||
{/* Level-Verteilung mit geometrischen Formen */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-2">
|
||||
{dist.beginner > 0 && (
|
||||
<div className="relative w-6 h-6 bg-red-500 rounded-full flex items-center justify-center" title={`${dist.beginner} Anfänger`}>
|
||||
<span className="text-white text-xs font-bold">{dist.beginner}</span>
|
||||
</div>
|
||||
)}
|
||||
{dist.intermediate > 0 && (
|
||||
<div className="relative w-6 h-6 bg-green-500 flex items-center justify-center" title={`${dist.intermediate} Fortgeschrittene`}>
|
||||
<span className="text-white text-xs font-bold">{dist.intermediate}</span>
|
||||
</div>
|
||||
)}
|
||||
{dist.expert > 0 && (
|
||||
<div className="relative flex items-center justify-center" title={`${dist.expert} Experten`}>
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
fill="rgb(147 51 234)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-white text-xs font-bold" style={{ fontSize: '10px' }}>{dist.expert}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-tertiary ml-auto">
|
||||
{skill.userCount} Mitarbeiter
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2 pt-4">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || (!freeSearchTerm && selectedSkills.length === 0)}
|
||||
className="flex-1 btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 mr-2" />
|
||||
{loading ? 'Suche läuft...' : 'Suchen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full btn-secondary"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSkills.length > 0 && (
|
||||
{selectedSkills.size > 0 && (
|
||||
<div className="card mt-6">
|
||||
<h3 className="text-body font-poppins font-semibold text-primary mb-3">
|
||||
Ausgewählte Skills ({selectedSkills.length})
|
||||
Ausgewählte Skills ({selectedSkills.size})
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSkills.map((skillId) => (
|
||||
<span key={skillId} className="badge badge-info">
|
||||
{getSkillNameById(skillId)}
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedSkills).map(skillId => (
|
||||
<div key={skillId} className="flex items-center justify-between">
|
||||
<span className="badge badge-info text-xs">
|
||||
{getSkillNameById(skillId)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSkillToggle(skillId)}
|
||||
className="ml-2 text-xs hover:text-error"
|
||||
onClick={() => handleSkillClick(skillId)}
|
||||
className="text-xs text-error hover:text-red-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSkills(new Set())
|
||||
setFilteredEmployees([])
|
||||
}}
|
||||
className="w-full mt-3 text-xs btn-secondary"
|
||||
>
|
||||
Alle abwählen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle column - Skills */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
|
||||
Suchergebnisse
|
||||
{hasSearched && searchResults.length > 0 && (
|
||||
Verfügbare Skills
|
||||
{selectedCategories.size > 0 && (
|
||||
<span className="ml-2 text-body font-normal text-tertiary">
|
||||
({searchResults.length} Mitarbeiter gefunden)
|
||||
({getFilteredSkills().length} Skills)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{!hasSearched ? (
|
||||
{selectedCategories.size === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<SearchIcon className="w-16 h-16 mx-auto text-text-placeholder mb-4" />
|
||||
<p className="text-tertiary">
|
||||
Geben Sie einen Suchbegriff ein oder wählen Sie Skills aus
|
||||
Wählen Sie eine Kategorie aus
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-tertiary">Suche läuft...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[600px] overflow-y-auto">
|
||||
{getFilteredSkills().map((skill) => {
|
||||
const isSelected = selectedSkills.has(skill.id)
|
||||
const dist = skill.levelDistribution
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleSkillClick(skill.id)}
|
||||
className={`p-3 rounded-lg border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? 'bg-blue-50 border-blue-400 shadow-md dark:bg-blue-900/20 dark:border-blue-500'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50 hover:border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-primary">{skill.name}</span>
|
||||
{isSelected && <span className="ml-2 text-blue-600">✓</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level-Verteilung mit geometrischen Formen */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-2">
|
||||
{dist.beginner > 0 && (
|
||||
<div className="relative w-6 h-6 bg-red-500 rounded-full flex items-center justify-center" title={`${dist.beginner} Anfänger`}>
|
||||
<span className="text-white text-xs font-bold">{dist.beginner}</span>
|
||||
</div>
|
||||
)}
|
||||
{dist.intermediate > 0 && (
|
||||
<div className="relative w-6 h-6 bg-green-500 flex items-center justify-center" title={`${dist.intermediate} Fortgeschrittene`}>
|
||||
<span className="text-white text-xs font-bold">{dist.intermediate}</span>
|
||||
</div>
|
||||
)}
|
||||
{dist.expert > 0 && (
|
||||
<div className="relative flex items-center justify-center" title={`${dist.expert} Experten`}>
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
fill="rgb(147 51 234)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-white text-xs font-bold" style={{ fontSize: '10px' }}>{dist.expert}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-tertiary ml-auto">
|
||||
{skill.userCount} total
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-tertiary">
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Employees */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
|
||||
Mitarbeiter
|
||||
{filteredEmployees.length > 0 && (
|
||||
<span className="ml-2 text-body font-normal text-tertiary">
|
||||
({filteredEmployees.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{selectedSkills.size === 0 && freeSearchTerm.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-tertiary text-sm">
|
||||
Wählen Sie einen Skill aus
|
||||
</p>
|
||||
</div>
|
||||
) : filteredEmployees.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-tertiary text-sm">
|
||||
Keine Mitarbeiter gefunden
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{searchResults.map((employee) => (
|
||||
<EmployeeCard
|
||||
key={employee.id}
|
||||
employee={employee}
|
||||
onClick={() => navigate(`/employees/${employee.id}`)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{filteredEmployees.map((employee: any) => {
|
||||
const skillLevel = parseInt(employee.selectedSkillLevel) || 0
|
||||
return (
|
||||
<button
|
||||
key={employee.id}
|
||||
onClick={() => navigate(`/employees/${employee.id}`)}
|
||||
className="w-full p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-tertiary">
|
||||
{employee.position}
|
||||
</div>
|
||||
</div>
|
||||
{selectedSkills.size > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{skillLevel >= 7 ? (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" title={`Experte (Level ${skillLevel})`}>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
fill="rgb(147 51 234)"
|
||||
/>
|
||||
</svg>
|
||||
) : skillLevel >= 4 ? (
|
||||
<div className="w-5 h-5 bg-green-500 flex items-center justify-center" title={`Fortgeschritten (Level ${skillLevel})`}>
|
||||
</div>
|
||||
) : skillLevel >= 1 ? (
|
||||
<div className="w-5 h-5 bg-red-500 rounded-full flex items-center justify-center" title={`Anfänger (Level ${skillLevel})`}>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
In neuem Issue referenzieren
Einen Benutzer sperren