Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-20 21:31:04 +02:00
Commit 6b9b6d4f20
1821 geänderte Dateien mit 348527 neuen und 0 gelöschten Zeilen

13
admin-panel/index.html Normale Datei
Datei anzeigen

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SkillMate Admin Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3456
admin-panel/package-lock.json generiert Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

31
admin-panel/package.json Normale Datei
Datei anzeigen

@ -0,0 +1,31 @@
{
"name": "@skillmate/admin-panel",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@skillmate/shared": "file:../shared",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"lucide-react": "^0.525.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
}
}

Datei anzeigen

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

49
admin-panel/src/App.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,49 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import Layout from './components/Layout'
import Login from './views/Login'
import Dashboard from './views/Dashboard'
import CreateEmployee from './views/CreateEmployee'
import SkillManagement from './views/SkillManagement'
import UserManagement from './views/UserManagement'
import EmailSettings from './views/EmailSettings'
import SyncSettings from './views/SyncSettings'
import { useEffect } from 'react'
function App() {
const { isAuthenticated } = useAuthStore()
useEffect(() => {
// Always use light mode for admin panel
document.documentElement.classList.remove('dark')
}, [])
if (!isAuthenticated) {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>
)
}
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/skills" element={<SkillManagement />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/users/create-employee" element={<CreateEmployee />} />
<Route path="/email-settings" element={<EmailSettings />} />
<Route path="/sync" element={<SyncSettings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</Router>
)
}
export default App

Datei anzeigen

@ -0,0 +1,7 @@
export default function HomeIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,85 @@
import { ReactNode } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import {
HomeIcon,
UsersIcon,
SettingsIcon,
MailIcon
} from './icons'
interface LayoutProps {
children: ReactNode
}
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon },
{ name: 'Skills verwalten', href: '/skills', icon: SettingsIcon },
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon },
]
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="flex h-screen bg-bg-main">
<div className="relative w-[260px] bg-white border-r border-border-default flex flex-col pb-28">
<div className="p-5">
<h1 className="text-title-card font-poppins font-semibold text-primary">
SkillMate Admin
</h1>
</div>
<nav className="px-5 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
}
>
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{item.name}</span>
</NavLink>
))}
</nav>
<div className="absolute bottom-0 left-0 right-0 p-5 border-t border-border-default">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-body font-medium text-secondary">{user?.username}</p>
<p className="text-small text-tertiary capitalize">{user?.role}</p>
</div>
</div>
<button
onClick={handleLogout}
className="btn-secondary w-full"
>
Abmelden
</button>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b border-border-default h-16 flex items-center px-container">
<h2 className="text-title-dialog font-poppins font-semibold text-primary">
Admin Panel
</h2>
</header>
<main className="flex-1 overflow-y-auto p-container bg-bg-main">
{children}
</main>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function MoonIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,8 @@
export default function SettingsIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function SunIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,130 @@
import { useState, useEffect } from 'react'
import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'
import { api } from '../services/api'
interface SyncStatusData {
pendingItems: number
recentSync: any[]
pendingConflicts: number
isSyncing: boolean
}
export default function SyncStatus() {
const [status, setStatus] = useState<SyncStatusData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchStatus()
// Refresh every 30 seconds
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [])
const fetchStatus = async () => {
try {
const response = await api.get('/sync/status')
setStatus(response.data.data)
setError('')
} catch (err) {
console.error('Failed to fetch sync status:', err)
setError('Fehler beim Abrufen des Sync-Status')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center text-red-600">
<AlertCircle className="w-5 h-5 mr-2" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
if (!status) return null
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Sync-Status</h3>
<button
onClick={fetchStatus}
className="text-gray-500 hover:text-gray-700"
>
<RefreshCw className={`w-5 h-5 ${status.isSyncing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-gray-500">Ausstehend</div>
<div className="text-2xl font-bold text-gray-900">{status.pendingItems}</div>
</div>
<div>
<div className="text-gray-500">Konflikte</div>
<div className="text-2xl font-bold text-orange-600">{status.pendingConflicts}</div>
</div>
<div>
<div className="text-gray-500">Status</div>
<div className="mt-1">
{status.isSyncing ? (
<span className="flex items-center text-blue-600">
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
Läuft...
</span>
) : (
<span className="flex items-center text-green-600">
<CheckCircle className="w-4 h-4 mr-1" />
Bereit
</span>
)}
</div>
</div>
</div>
{status.recentSync && status.recentSync.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Sync-Vorgänge</h4>
<div className="space-y-1">
{status.recentSync.slice(0, 3).map((sync, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-gray-600">
{sync.sync_type} - {sync.sync_action}
</span>
<span className={`px-2 py-1 rounded-full ${
sync.status === 'completed' ? 'bg-green-100 text-green-800' :
sync.status === 'failed' ? 'bg-red-100 text-red-800' :
sync.status === 'conflict' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'
}`}>
{sync.status === 'completed' ? 'Erfolgreich' :
sync.status === 'failed' ? 'Fehlgeschlagen' :
sync.status === 'conflict' ? 'Konflikt' :
'Ausstehend'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function UsersIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,110 @@
export const DashboardIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
export const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)
export const SkillsIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
)
export const UserManagementIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
)
export const SyncIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
)
export const LogoutIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
)
export const HomeIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
export const SettingsIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
interface IconProps {
className?: string
}
export function PencilIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
}
export function TrashIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)
}
export function ShieldIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)
}
export function KeyIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
)
}
export function UserIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
export const MailIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
)
export const ToggleLeftIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12H9m6 0a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
export const ToggleRightIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)

Datei anzeigen

@ -0,0 +1,6 @@
export { default as HomeIcon } from './HomeIcon'
export { default as UsersIcon } from './UsersIcon'
export { default as SearchIcon } from './SearchIcon'
export { default as SettingsIcon } from './SettingsIcon'
export { default as SunIcon } from './SunIcon'
export { default as MoonIcon } from './MoonIcon'

198
admin-panel/src/index.css Normale Datei
Datei anzeigen

@ -0,0 +1,198 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary-blue: #3182CE;
--primary-blue-hover: #2563EB;
--primary-blue-active: #1D4ED8;
--primary-blue-dark: #1E40AF;
--bg-main: #F8FAFC;
--bg-white: #FFFFFF;
--bg-gray: #F0F4F8;
--bg-accent: #E6F2FF;
--text-primary: #1A365D;
--text-secondary: #2D3748;
--text-tertiary: #4A5568;
--text-quaternary: #718096;
--text-placeholder: #A0AEC0;
--border-default: #E2E8F0;
--border-input: #CBD5E0;
--success: #059669;
--warning: #D97706;
--error: #DC2626;
--info: #2563EB;
}
.dark {
--primary-blue: #232D53;
--primary-blue-hover: #232D53;
--primary-blue-active: #232D53;
--bg-main: #000000;
--bg-white: #1A1F3A;
--bg-gray: #232D53;
--bg-accent: #232D53;
--text-primary: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.6);
--text-quaternary: rgba(255, 255, 255, 0.5);
--text-placeholder: rgba(255, 255, 255, 0.4);
--border-default: rgba(255, 255, 255, 0.1);
--border-input: rgba(255, 255, 255, 0.2);
--success: #4CAF50;
--warning: #FFC107;
--error: #FF4444;
--info: #2196F3;
}
* {
@apply transition-colors duration-default ease-default;
}
body {
@apply bg-bg-main text-text-secondary font-sans;
}
.dark body {
@apply bg-dark-bg text-dark-text-primary;
}
}
@layer components {
.btn-primary {
@apply bg-primary-blue text-white rounded-button h-12 px-8 font-poppins font-semibold text-nav
hover:bg-primary-blue-hover active:bg-primary-blue-active shadow-sm
transition-all duration-default ease-default
dark:bg-dark-accent dark:text-dark-bg dark:hover:bg-dark-accent-hover dark:hover:text-white;
}
.btn-secondary {
@apply bg-transparent text-text-primary border border-border-default rounded-button h-12 px-8
font-poppins font-semibold text-nav hover:bg-bg-main hover:border-border-input
transition-all duration-default ease-default
dark:text-white dark:border-dark-primary dark:hover:bg-dark-primary;
}
.input-field {
@apply bg-white border border-border-input rounded-input px-4 py-3 text-text-secondary
placeholder:text-text-placeholder focus:border-primary-blue focus:shadow-focus
focus:outline-none transition-all duration-fast
dark:bg-dark-primary dark:border-transparent dark:text-white
dark:placeholder:text-dark-text-tertiary dark:focus:bg-dark-bg-focus;
}
.card {
@apply bg-white border border-border-default rounded-card p-card shadow-sm
hover:border-primary-blue hover:bg-bg-main hover:shadow-md hover:-translate-y-0.5
transition-all duration-default ease-default cursor-pointer
dark:bg-dark-bg-secondary dark:border-transparent dark:hover:border-dark-accent
dark:hover:bg-dark-primary;
}
.badge {
@apply px-3 py-1 rounded-badge text-help font-semibold uppercase tracking-wide;
}
.badge-success {
@apply bg-success-bg text-success dark:bg-success dark:text-white;
}
.badge-warning {
@apply bg-warning-bg text-warning dark:bg-warning dark:text-dark-bg;
}
.badge-error {
@apply bg-error-bg text-error dark:bg-error dark:text-white;
}
.badge-info {
@apply bg-info-bg text-info dark:bg-info dark:text-white;
}
.sidebar-item {
@apply flex items-center px-4 py-3 rounded-input text-nav font-medium
hover:bg-bg-main transition-all duration-fast cursor-pointer
dark:hover:bg-dark-primary/50;
}
.sidebar-item-active {
@apply bg-bg-accent text-primary-blue-dark border-l-4 border-primary-blue
dark:bg-dark-primary dark:text-white dark:border-dark-accent;
}
.dialog {
@apply bg-white border border-border-default rounded-input shadow-xl
dark:bg-dark-bg dark:border-dark-border;
}
.dialog-header {
@apply bg-bg-gray h-10 flex items-center justify-between px-4 rounded-t-input
dark:bg-dark-primary;
}
.table-header {
@apply bg-bg-gray text-text-primary font-semibold border-b border-border-default
dark:bg-dark-primary dark:text-white dark:border-dark-border;
}
.table-row {
@apply bg-white hover:bg-bg-main border-b border-border-default transition-colors duration-fast
dark:bg-transparent dark:hover:bg-dark-bg-secondary dark:border-dark-border;
}
.scrollbar {
@apply scrollbar-thin scrollbar-track-divider scrollbar-thumb-border-input
hover:scrollbar-thumb-text-placeholder
dark:scrollbar-track-dark-bg-secondary dark:scrollbar-thumb-dark-accent
dark:hover:scrollbar-thumb-dark-accent-hover;
}
}
@layer utilities {
.text-primary {
@apply text-text-primary dark:text-white;
}
.text-secondary {
@apply text-text-secondary dark:text-dark-text-secondary;
}
.text-tertiary {
@apply text-text-tertiary dark:text-dark-text-tertiary;
}
.bg-primary {
@apply bg-bg-main dark:bg-dark-bg;
}
.bg-secondary {
@apply bg-white dark:bg-dark-bg-secondary;
}
.bg-tertiary {
@apply bg-bg-gray dark:bg-dark-primary;
}
.border-primary {
@apply border-border-default dark:border-dark-border;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.-webkit-app-region-drag {
-webkit-app-region: drag;
}
.-webkit-app-region-no-drag {
-webkit-app-region: no-drag;
}
}

10
admin-panel/src/main.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

Datei anzeigen

@ -0,0 +1,39 @@
import axios from 'axios'
import { useAuthStore } from '../stores/authStore'
const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

Datei anzeigen

@ -0,0 +1,66 @@
import api from './api'
export interface NetworkNode {
id: string
name: string
location: string
ipAddress: string
port: number
apiKey: string
isOnline: boolean
lastSync: Date | null
lastPing: Date | null
type: 'admin' | 'local'
}
export interface SyncSettings {
autoSyncInterval: string
conflictResolution: 'admin' | 'newest' | 'manual'
syncEmployees: boolean
syncSkills: boolean
syncUsers: boolean
syncSettings: boolean
bandwidthLimit: number | null
}
export const networkApi = {
// Network nodes
getNodes: async (): Promise<NetworkNode[]> => {
const response = await api.get('/network/nodes')
return response.data.data
},
createNode: async (node: Partial<NetworkNode>): Promise<{ id: string; apiKey: string }> => {
const response = await api.post('/network/nodes', node)
return response.data.data
},
updateNode: async (id: string, updates: Partial<NetworkNode>): Promise<void> => {
await api.put(`/network/nodes/${id}`, updates)
},
deleteNode: async (id: string): Promise<void> => {
await api.delete(`/network/nodes/${id}`)
},
pingNode: async (id: string): Promise<{ isOnline: boolean; lastPing: string; responseTime: number | null }> => {
const response = await api.post(`/network/nodes/${id}/ping`)
return response.data.data
},
// Sync settings
getSyncSettings: async (): Promise<SyncSettings> => {
const response = await api.get('/network/sync-settings')
return response.data.data
},
updateSyncSettings: async (settings: Partial<SyncSettings>): Promise<void> => {
await api.put('/network/sync-settings', settings)
},
// Sync operations
triggerSync: async (nodeIds?: string[]): Promise<{ syncedAt: string; nodeCount: number | string }> => {
const response = await api.post('/network/sync/trigger', { nodeIds })
return response.data.data
}
}

Datei anzeigen

@ -0,0 +1,26 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '@skillmate/shared'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (user: User, token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
}
)
)

Datei anzeigen

@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
isDarkMode: boolean
toggleTheme: () => void
setTheme: (isDark: boolean) => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
isDarkMode: false,
toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
setTheme: (isDark) => set({ isDarkMode: isDark }),
}),
{
name: 'theme-storage',
}
)
)

Datei anzeigen

@ -0,0 +1,203 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary-blue: #3182CE;
--primary-blue-hover: #2563EB;
--primary-blue-active: #1D4ED8;
--primary-blue-dark: #1E40AF;
--bg-main: #F8FAFC;
--bg-white: #FFFFFF;
--bg-gray: #F0F4F8;
--bg-accent: #E6F2FF;
--text-primary: #1A365D;
--text-secondary: #2D3748;
--text-tertiary: #4A5568;
--text-quaternary: #718096;
--text-placeholder: #A0AEC0;
--border-default: #E2E8F0;
--border-input: #CBD5E0;
--success: #059669;
--warning: #D97706;
--error: #DC2626;
--info: #2563EB;
}
.dark {
--primary-blue: #232D53;
--primary-blue-hover: #232D53;
--primary-blue-active: #232D53;
--bg-main: #000000;
--bg-white: #1A1F3A;
--bg-gray: #232D53;
--bg-accent: #232D53;
--text-primary: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.6);
--text-quaternary: rgba(255, 255, 255, 0.5);
--text-placeholder: rgba(255, 255, 255, 0.4);
--border-default: rgba(255, 255, 255, 0.1);
--border-input: rgba(255, 255, 255, 0.2);
--success: #4CAF50;
--warning: #FFC107;
--error: #FF4444;
--info: #2196F3;
}
* {
@apply transition-colors duration-default ease-default;
}
body {
@apply bg-bg-main text-text-secondary font-sans;
}
.dark body {
@apply bg-dark-bg text-dark-text-primary;
}
}
@layer components {
.btn-primary {
@apply bg-primary-blue text-white rounded-button h-12 px-8 font-poppins font-semibold text-nav
hover:bg-primary-blue-hover active:bg-primary-blue-active shadow-sm
transition-all duration-default ease-default
dark:bg-dark-accent dark:text-dark-bg dark:hover:bg-dark-accent-hover dark:hover:text-white;
}
.btn-secondary {
@apply bg-transparent text-text-primary border border-border-default rounded-button h-12 px-8
font-poppins font-semibold text-nav hover:bg-bg-main hover:border-border-input
transition-all duration-default ease-default
dark:text-white dark:border-dark-primary dark:hover:bg-dark-primary;
}
.input-field {
@apply bg-white border border-border-input rounded-input px-4 py-3 text-text-secondary
placeholder:text-text-placeholder focus:border-primary-blue focus:shadow-focus
focus:outline-none transition-all duration-fast
dark:bg-dark-primary dark:border-transparent dark:text-white
dark:placeholder:text-dark-text-tertiary dark:focus:bg-dark-bg-focus;
}
.card {
@apply bg-white border border-border-default rounded-card p-card shadow-sm
hover:border-primary-blue hover:bg-bg-main hover:shadow-md hover:-translate-y-0.5
transition-all duration-default ease-default cursor-pointer
dark:bg-dark-bg-secondary dark:border-transparent dark:hover:border-dark-accent
dark:hover:bg-dark-primary;
}
.form-card {
@apply bg-white border border-gray-300 rounded-lg p-6 shadow-sm
dark:bg-gray-800 dark:border-gray-600;
}
.badge {
@apply px-3 py-1 rounded-badge text-help font-semibold uppercase tracking-wide;
}
.badge-success {
@apply bg-success-bg text-success dark:bg-success dark:text-white;
}
.badge-warning {
@apply bg-warning-bg text-warning dark:bg-warning dark:text-dark-bg;
}
.badge-error {
@apply bg-error-bg text-error dark:bg-error dark:text-white;
}
.badge-info {
@apply bg-info-bg text-info dark:bg-info dark:text-white;
}
.sidebar-item {
@apply flex items-center px-4 py-3 rounded-input text-nav font-medium
hover:bg-bg-main transition-all duration-fast cursor-pointer
dark:hover:bg-dark-primary/50;
}
.sidebar-item-active {
@apply bg-bg-accent text-primary-blue-dark border-l-4 border-primary-blue
dark:bg-dark-primary dark:text-white dark:border-dark-accent;
}
.dialog {
@apply bg-white border border-border-default rounded-input shadow-xl
dark:bg-dark-bg dark:border-dark-border;
}
.dialog-header {
@apply bg-bg-gray h-10 flex items-center justify-between px-4 rounded-t-input
dark:bg-dark-primary;
}
.table-header {
@apply bg-bg-gray text-text-primary font-semibold border-b border-border-default
dark:bg-dark-primary dark:text-white dark:border-dark-border;
}
.table-row {
@apply bg-white hover:bg-bg-main border-b border-border-default transition-colors duration-fast
dark:bg-transparent dark:hover:bg-dark-bg-secondary dark:border-dark-border;
}
.scrollbar {
@apply scrollbar-thin scrollbar-track-divider scrollbar-thumb-border-input
hover:scrollbar-thumb-text-placeholder
dark:scrollbar-track-dark-bg-secondary dark:scrollbar-thumb-dark-accent
dark:hover:scrollbar-thumb-dark-accent-hover;
}
}
@layer utilities {
.text-primary {
@apply text-text-primary dark:text-white;
}
.text-secondary {
@apply text-text-secondary dark:text-dark-text-secondary;
}
.text-tertiary {
@apply text-text-tertiary dark:text-dark-text-tertiary;
}
.bg-primary {
@apply bg-bg-main dark:bg-dark-bg;
}
.bg-secondary {
@apply bg-white dark:bg-dark-bg-secondary;
}
.bg-tertiary {
@apply bg-bg-gray dark:bg-dark-primary;
}
.border-primary {
@apply border-border-default dark:border-dark-border;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.-webkit-app-region-drag {
-webkit-app-region: drag;
}
.-webkit-app-region-no-drag {
-webkit-app-region: no-drag;
}
}

Datei anzeigen

@ -0,0 +1,281 @@
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface CreateEmployeeData {
firstName: string
lastName: string
email: string
department: string
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function CreateEmployee() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const [creationDone, setCreationDone] = useState(false)
const { register, handleSubmit, formState: { errors }, watch } = useForm<CreateEmployeeData>({
defaultValues: {
userRole: 'user',
createUser: true
}
})
const watchCreateUser = watch('createUser')
const onSubmit = async (data: CreateEmployeeData) => {
try {
setLoading(true)
setError('')
setSuccess('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
department: data.department,
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
const response = await api.post('/employees', payload)
if (response.data.data?.temporaryPassword) {
setCreatedUser({ password: response.data.data.temporaryPassword })
setSuccess(`Mitarbeiter und Benutzerkonto erfolgreich erstellt!`)
} else {
setSuccess('Mitarbeiter erfolgreich erstellt!')
}
// Manuelles Weiterklicken statt Auto-Navigation, damit Passwort kopiert werden kann
setCreationDone(true)
} catch (err: any) {
console.error('Error:', err.response?.data)
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
const errorDetails = err.response?.data?.error?.details
if (errorDetails && Array.isArray(errorDetails)) {
const detailMessages = errorDetails.map((d: any) => d.msg || d.message).join(', ')
setError(`${errorMessage}: ${detailMessages}`)
} else {
setError(errorMessage)
}
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/users')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Benutzerverwaltung
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
Neuen Mitarbeiter & Benutzer anlegen
</h1>
<p className="text-body text-secondary mt-2">
Erstellen Sie einen neuen Mitarbeiter-Datensatz und optional ein Benutzerkonto
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
{createdUser.password && (
<div className="mt-4 p-4 bg-white rounded border border-green-300">
<h4 className="font-semibold mb-2">🔑 Temporäres Passwort:</h4>
<code className="text-lg bg-gray-100 px-3 py-2 rounded block">
{createdUser.password}
</code>
<p className="text-sm mt-2 text-green-700">
Bitte notieren Sie dieses Passwort und geben Sie es sicher an den Mitarbeiter weiter.
Das Passwort muss beim ersten Login geändert werden.
</p>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => navigate('/users')}
className="btn-primary"
>
Weiter zur Benutzerverwaltung
</button>
</div>
</div>
)}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Mitarbeiter-Grunddaten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname *
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname *
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div className="col-span-2">
<label className="block text-body font-medium text-secondary mb-2">
E-Mail *
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div className="col-span-2">
<label className="block text-body font-medium text-secondary mb-2">
Abteilung *
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Marketing, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body font-medium text-secondary">
Benutzerkonto für System-Zugang erstellen
</span>
</label>
<p className="text-sm text-secondary-light mt-2 ml-7">
Erstellt ein Benutzerkonto, mit dem sich der Mitarbeiter im System anmelden kann.
Ein sicheres temporäres Passwort wird automatisch generiert.
</p>
</div>
{watchCreateUser && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Benutzerrolle *
</label>
<select
{...register('userRole')}
className="input-field w-full"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<p className="text-sm text-secondary-light mt-2">
{getRoleDescription(watch('userRole'))}
</p>
</div>
)}
</div>
</div>
<div className="card mb-6 bg-blue-50 border-blue-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
📋 Was passiert als Nächstes?
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> Der Mitarbeiter wird mit Grunddaten angelegt (Position: "Mitarbeiter", Telefon: "Nicht angegeben")</li>
<li> {watchCreateUser ? 'Ein Benutzerkonto wird erstellt und ein temporäres Passwort generiert' : 'Kein Benutzerkonto wird erstellt'}</li>
<li> Der Mitarbeiter kann später im Frontend seine Profildaten vervollständigen</li>
<li> Alle Daten werden verschlüsselt in der Datenbank gespeichert</li>
</ul>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/users')}
className="btn-secondary"
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Erstelle...' : 'Mitarbeiter erstellen'}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import SyncStatus from '../components/SyncStatus'
interface DashboardStats {
totalEmployees: number
totalSkills: number
totalUsers: number
lastSync?: {
timestamp: string
success: boolean
itemsSynced: number
}
}
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats>({
totalEmployees: 0,
totalSkills: 0,
totalUsers: 0,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
try {
const [employeesRes, hierarchyRes, usersRes] = await Promise.all([
// Zähle nur Mitarbeitende mit verknüpftem aktivem Benutzerkonto (wie im Frontend)
api.get('/employees/public'),
// Zähle nur in der Hierarchie sichtbare Skills (entspricht Skill-Verwaltung)
api.get('/skills/hierarchy'),
api.get('/admin/users'),
])
const publicEmployees = employeesRes.data.data || []
const users = (usersRes.data.data || [])
.filter((u: any) => u.username !== 'admin' && u.isActive !== false)
// Summe Skills aus Hierarchie bilden
const hierarchy = (hierarchyRes.data.data || []) as any[]
const totalSkills = hierarchy.reduce((sum, cat) => sum + (cat.subcategories || []).reduce((s2: number, sub: any) => s2 + ((sub.skills || []).length), 0), 0)
setStats({
totalEmployees: publicEmployees.length,
totalSkills,
totalUsers: users.length,
})
} catch (error) {
console.error('Failed to fetch stats:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-tertiary">Daten werden geladen...</p>
</div>
)
}
const statsCards = [
{
title: 'Mitarbeiter',
value: stats.totalEmployees,
color: 'text-primary-blue',
bgColor: 'bg-bg-accent',
},
{
title: 'Skills',
value: stats.totalSkills,
color: 'text-success',
bgColor: 'bg-success-bg',
},
{
title: 'Benutzer',
value: stats.totalUsers,
color: 'text-info',
bgColor: 'bg-info-bg',
},
]
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
Dashboard
</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{statsCards.map((stat, index) => (
<div key={index} className="card">
<div className={`w-16 h-16 rounded-card ${stat.bgColor} flex items-center justify-center mb-4`}>
<span className={`text-2xl font-bold ${stat.color}`}>
{stat.value}
</span>
</div>
<h3 className="text-body font-medium text-tertiary">
{stat.title}
</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Letzte Synchronisation
</h2>
{stats.lastSync ? (
<div className="space-y-2">
<p className="text-body text-secondary">
<span className="font-medium">Zeitpunkt:</span>{' '}
{new Date(stats.lastSync.timestamp).toLocaleString('de-DE')}
</p>
<p className="text-body text-secondary">
<span className="font-medium">Status:</span>{' '}
<span className={stats.lastSync.success ? 'text-success' : 'text-error'}>
{stats.lastSync.success ? 'Erfolgreich' : 'Fehlgeschlagen'}
</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Synchronisierte Elemente:</span>{' '}
{stats.lastSync.itemsSynced}
</p>
</div>
) : (
<p className="text-body text-tertiary">
Noch keine Synchronisation durchgeführt
</p>
)}
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Systemstatus
</h2>
<div className="space-y-2">
<p className="text-body text-secondary">
<span className="font-medium">Backend:</span>{' '}
<span className="text-success">Online</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Datenbank:</span>{' '}
<span className="text-success">Verbunden</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Version:</span> 1.0.0
</p>
</div>
</div>
</div>
<SyncStatus />
</div>
)
}

Datei anzeigen

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { MailIcon, ToggleLeftIcon, ToggleRightIcon } from '../components/icons'
interface SystemSettings {
[key: string]: {
value: boolean | string
description?: string
}
}
export default function EmailSettings() {
const [settings, setSettings] = useState<SystemSettings>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
setLoading(true)
const response = await api.get('/admin/settings')
setSettings(response.data.data)
} catch (err: any) {
console.error('Failed to fetch settings:', err)
setError('Einstellungen konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const handleToggleSetting = async (key: string, currentValue: boolean) => {
try {
setSaving(true)
setError('')
setSuccess('')
await api.put(`/admin/settings/${key}`, {
value: !currentValue
})
// Update local state
setSettings(prev => ({
...prev,
[key]: {
...prev[key],
value: !currentValue
}
}))
setSuccess('Einstellung erfolgreich gespeichert')
setTimeout(() => setSuccess(''), 3000)
} catch (err: any) {
console.error('Failed to update setting:', err)
setError('Einstellung konnte nicht gespeichert werden')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Einstellungen...</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">
E-Mail-Einstellungen
</h1>
<p className="text-body text-secondary">
Konfigurieren Sie die automatischen E-Mail-Benachrichtigungen des Systems
</p>
</div>
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
</div>
)}
<div className="card">
<div className="flex items-center mb-6">
<MailIcon className="w-6 h-6 text-primary-blue mr-3" />
<h2 className="text-title-card font-poppins font-semibold text-primary">
Benachrichtigungseinstellungen
</h2>
</div>
<div className="space-y-6">
{Object.entries(settings).map(([key, setting]) => (
<div key={key} className="flex items-center justify-between p-4 border border-border-light rounded-input hover:bg-bg-hover transition-colors">
<div className="flex-1">
<h3 className="text-body font-medium text-primary mb-1">
{key === 'email_notifications_enabled' ? 'Passwort-E-Mails versenden' : key}
</h3>
<p className="text-small text-secondary">
{setting.description || 'Automatische E-Mail-Benachrichtigungen für neue Benutzerpasswörter'}
</p>
</div>
<div className="ml-4">
<button
onClick={() => handleToggleSetting(key, setting.value as boolean)}
disabled={saving}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-colors ${
setting.value
? 'bg-primary-blue text-white'
: 'bg-gray-200 text-gray-600'
} ${saving ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}`}
>
{setting.value ? (
<>
<ToggleRightIcon className="w-5 h-5" />
<span className="text-sm font-medium">Ein</span>
</>
) : (
<>
<ToggleLeftIcon className="w-5 h-5" />
<span className="text-sm font-medium">Aus</span>
</>
)}
</button>
</div>
</div>
))}
</div>
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-input">
<h3 className="text-body font-semibold text-primary mb-2">
💡 Hinweise zur E-Mail-Konfiguration
</h3>
<ul className="text-small text-secondary space-y-1">
<li> E-Mail-Versand erfordert die Konfiguration von SMTP-Einstellungen in den Umgebungsvariablen</li>
<li> Umgebungsvariablen: EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM</li>
<li> Die Standardeinstellung ist "Aus" aus Datenschutzgründen</li>
<li> Passwörter werden nur einmal per E-Mail versendet und können nicht erneut abgerufen werden</li>
</ul>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,251 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface EmployeeFormData {
firstName: string
lastName: string
email: string
department: string
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function EmployeeForm() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { register, handleSubmit, formState: { errors }, reset } = useForm<EmployeeFormData>()
useEffect(() => {
if (isEdit) {
fetchEmployee()
}
}, [id])
const fetchEmployee = async () => {
try {
const response = await api.get(`/employees/${id}`)
const employee = response.data.data
reset({
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
department: employee.department,
userRole: 'user',
createUser: false
})
} catch (error) {
console.error('Failed to fetch employee:', error)
setError('Mitarbeiter konnte nicht geladen werden')
}
}
const onSubmit = async (data: EmployeeFormData) => {
try {
setLoading(true)
setError('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
department: data.department,
// Optional fields - Backend will handle defaults
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
if (isEdit) {
await api.put(`/employees/${id}`, payload)
} else {
await api.post('/employees', payload)
}
navigate('/employees')
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Speichern fehlgeschlagen')
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/employees')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Übersicht
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
{isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Mitarbeiterdaten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
E-Mail
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Abteilung
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Buchhaltung, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto erstellen (optional)
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body text-secondary">
Benutzerkonto für diesen Mitarbeiter erstellen
</span>
</label>
<p className="text-small text-tertiary mt-1">
Wenn aktiviert, wird automatisch ein Benutzerkonto mit der E-Mail-Adresse erstellt
</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nutzerrolle
</label>
<div className="space-y-3">
{(['admin', 'superuser', 'user'] as const).map((role) => (
<label key={role} className="flex items-start space-x-3 cursor-pointer group">
<input
{...register('userRole', { required: false })}
type="radio"
value={role}
className="w-5 h-5 mt-0.5 text-primary-blue focus:ring-primary-blue"
/>
<div className="flex-1">
<div className="font-medium text-secondary capitalize">
{role === 'admin' ? 'Administrator' :
role === 'superuser' ? 'Superuser' : 'Benutzer'}
</div>
<div className="text-small text-tertiary mt-1" title={getRoleDescription(role)}>
{getRoleDescription(role)}
</div>
</div>
</label>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Speichern...' : (isEdit ? 'Änderungen speichern' : 'Mitarbeiter anlegen')}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,398 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface EmployeeFormData {
firstName: string
lastName: string
employeeNumber: string
email: string
phone: string
position: string
department: string
office?: string
mobile?: string
availability: 'available' | 'busy' | 'away' | 'unavailable'
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function EmployeeFormComplete() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({
defaultValues: {
availability: 'available',
userRole: 'user',
createUser: false
}
})
const watchCreateUser = watch('createUser')
useEffect(() => {
if (isEdit) {
fetchEmployee()
}
}, [id])
const fetchEmployee = async () => {
try {
const response = await api.get(`/employees/${id}`)
const employee = response.data.data
reset({
firstName: employee.firstName,
lastName: employee.lastName,
employeeNumber: employee.employeeNumber,
email: employee.email,
phone: employee.phone,
position: employee.position,
department: employee.department,
office: employee.office,
mobile: employee.mobile,
availability: employee.availability,
userRole: 'user',
createUser: false
})
} catch (error) {
console.error('Failed to fetch employee:', error)
setError('Mitarbeiter konnte nicht geladen werden')
}
}
const onSubmit = async (data: EmployeeFormData) => {
try {
setLoading(true)
setError('')
setSuccess('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
employeeNumber: data.employeeNumber || `EMP${Date.now()}`,
email: data.email,
phone: data.phone,
position: data.position,
department: data.department,
office: data.office || null,
mobile: data.mobile || null,
availability: data.availability,
skills: [],
languages: [],
specializations: [],
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
let response
if (isEdit) {
response = await api.put(`/employees/${id}`, payload)
setSuccess('Mitarbeiter erfolgreich aktualisiert!')
} else {
response = await api.post('/employees', payload)
if (response.data.data.temporaryPassword) {
setCreatedUser({ password: response.data.data.temporaryPassword })
setSuccess(`Mitarbeiter erfolgreich erstellt! Temporäres Passwort: ${response.data.data.temporaryPassword}`)
} else {
setSuccess('Mitarbeiter erfolgreich erstellt!')
}
}
setTimeout(() => {
navigate('/employees')
}, 3000)
} catch (err: any) {
console.error('Error:', err.response?.data)
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
const errorDetails = err.response?.data?.error?.details
if (errorDetails && Array.isArray(errorDetails)) {
const detailMessages = errorDetails.map((d: any) => d.msg || d.message).join(', ')
setError(`${errorMessage}: ${detailMessages}`)
} else {
setError(errorMessage)
}
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/employees')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Übersicht
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
{isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
{createdUser.password && (
<div className="mt-2 p-2 bg-white rounded border border-green-300">
<strong>Bitte notieren Sie das temporäre Passwort:</strong>
<br />
<code className="text-lg">{createdUser.password}</code>
</div>
)}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Persönliche Daten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname *
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname *
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Mitarbeiternummer *
</label>
<input
{...register('employeeNumber', { required: 'Mitarbeiternummer ist erforderlich' })}
className="input-field w-full"
placeholder="EMP001"
/>
{errors.employeeNumber && (
<p className="text-error text-sm mt-1">{errors.employeeNumber.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
E-Mail *
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Kontaktdaten & Position
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Telefon *
</label>
<input
{...register('phone', { required: 'Telefonnummer ist erforderlich' })}
className="input-field w-full"
placeholder="+49 123 456789"
/>
{errors.phone && (
<p className="text-error text-sm mt-1">{errors.phone.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Mobil (optional)
</label>
<input
{...register('mobile')}
className="input-field w-full"
placeholder="+49 170 123456"
/>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Position *
</label>
<input
{...register('position', { required: 'Position ist erforderlich' })}
className="input-field w-full"
placeholder="Software Developer"
/>
{errors.position && (
<p className="text-error text-sm mt-1">{errors.position.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Abteilung *
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Buchhaltung, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Büro (optional)
</label>
<input
{...register('office')}
className="input-field w-full"
placeholder="Gebäude A, Raum 123"
/>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Verfügbarkeit *
</label>
<select
{...register('availability', { required: 'Verfügbarkeit ist erforderlich' })}
className="input-field w-full"
>
<option value="available">Verfügbar</option>
<option value="busy">Beschäftigt</option>
<option value="away">Abwesend</option>
<option value="unavailable">Nicht verfügbar</option>
</select>
{errors.availability && (
<p className="text-error text-sm mt-1">{errors.availability.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto erstellen (optional)
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body font-medium text-secondary">
Benutzerkonto für diesen Mitarbeiter erstellen
</span>
</label>
<p className="text-sm text-secondary-light mt-1 ml-7">
Ein temporäres Passwort wird generiert und muss beim ersten Login geändert werden.
</p>
</div>
{watchCreateUser && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Benutzerrolle
</label>
<select
{...register('userRole')}
className="input-field w-full"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<p className="text-sm text-secondary-light mt-1">
{getRoleDescription(watch('userRole'))}
</p>
</div>
)}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Speichert...' : (isEdit ? 'Aktualisieren' : 'Erstellen')}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { api } from '../services/api'
export default function EmployeeManagement() {
const navigate = useNavigate()
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
fetchEmployees()
}, [])
const fetchEmployees = async () => {
try {
const response = await api.get('/employees')
setEmployees(response.data.data)
} catch (error) {
console.error('Failed to fetch employees:', error)
} finally {
setLoading(false)
}
}
const filteredEmployees = employees.filter(emp =>
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.employeeNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.department.toLowerCase().includes(searchTerm.toLowerCase())
)
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-tertiary">Mitarbeiter werden geladen...</p>
</div>
)
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
Mitarbeiterverwaltung
</h1>
<button
onClick={() => navigate('/employees/new')}
className="btn-primary"
>
Neuer Mitarbeiter
</button>
</div>
<div className="card mb-6">
<input
type="text"
placeholder="Suche nach Name, Personalnummer oder Abteilung..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input-field w-full"
/>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="table-header">
<th className="px-4 py-3 text-left">Personalnr.</th>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Position</th>
<th className="px-4 py-3 text-left">Abteilung</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((employee) => (
<tr key={employee.id} className="table-row">
<td className="px-4 py-3">{employee.employeeNumber}</td>
<td className="px-4 py-3">
{employee.firstName} {employee.lastName}
</td>
<td className="px-4 py-3">{employee.position}</td>
<td className="px-4 py-3">{employee.department}</td>
<td className="px-4 py-3">
<span className={`badge ${
employee.availability === 'available' ? 'badge-success' : 'badge-warning'
}`}>
{employee.availability}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/employees/${employee.id}/edit`)}
className="text-primary-blue hover:text-primary-blue-hover mr-3"
>
Bearbeiten
</button>
<button className="text-error hover:text-error/80">
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredEmployees.length === 0 && (
<div className="text-center py-8">
<p className="text-tertiary">Keine Mitarbeiter gefunden</p>
</div>
)}
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,99 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useAuthStore } from '../stores/authStore'
import { api } from '../services/api'
import type { LoginRequest } from '@skillmate/shared'
export default function Login() {
const navigate = useNavigate()
const { login } = useAuthStore()
const [error, setError] = useState('')
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginRequest>()
const onSubmit = async (data: LoginRequest) => {
try {
setError('')
const response = await api.post('/auth/login', data)
if (response.data.success) {
const { user, token } = response.data.data
login(user, token.accessToken)
navigate('/')
}
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Anmeldung fehlgeschlagen')
}
}
return (
<div className="min-h-screen bg-bg-main flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="card">
<div className="text-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
SkillMate Admin
</h1>
<p className="text-body text-tertiary mt-2">
Melden Sie sich an, um fortzufahren
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-body font-medium text-secondary mb-2">
Benutzername
</label>
<input
{...register('username', { required: 'Benutzername ist erforderlich' })}
type="text"
id="username"
className="input-field w-full"
placeholder="admin"
/>
{errors.username && (
<p className="text-error text-sm mt-1">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-body font-medium text-secondary mb-2">
Passwort
</label>
<input
{...register('password', { required: 'Passwort ist erforderlich' })}
type="password"
id="password"
className="input-field w-full"
placeholder="••••••••"
/>
{errors.password && (
<p className="text-error text-sm mt-1">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-small text-tertiary">
Standard-Login: admin / admin123
</p>
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,283 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
type Skill = { id: string; name: string; description?: string | null }
type Subcategory = { id: string; name: string; skills: Skill[] }
type Category = { id: string; name: string; subcategories: Subcategory[] }
export default function SkillManagement() {
const [hierarchy, setHierarchy] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [openMain, setOpenMain] = useState<Record<string, boolean>>({})
const [openSub, setOpenSub] = useState<Record<string, boolean>>({})
// Inline create/edit states
const [addingCat, setAddingCat] = useState(false)
const [newCat, setNewCat] = useState({ name: '' })
const [addingSub, setAddingSub] = useState<Record<string, boolean>>({})
const [newSub, setNewSub] = useState<Record<string, { name: string }>>({})
const [addingSkill, setAddingSkill] = useState<Record<string, boolean>>({}) // key: catId.subId
const [newSkill, setNewSkill] = useState<Record<string, { name: string; description?: string }>>({})
const [editingCat, setEditingCat] = useState<string | null>(null)
const [editCatName, setEditCatName] = useState<string>('')
const [editingSub, setEditingSub] = useState<string | null>(null) // key: catId.subId
const [editSubName, setEditSubName] = useState<string>('')
const [editingSkill, setEditingSkill] = useState<string | null>(null) // skill id
const [editSkillData, setEditSkillData] = useState<{ name: string; description?: string }>({ name: '' })
useEffect(() => { fetchHierarchy() }, [])
async function fetchHierarchy() {
try {
setLoading(true)
const res = await api.get('/skills/hierarchy')
setHierarchy(res.data.data || [])
} catch (e) {
setError('Skills konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
function keyFor(catId: string, subId: string) { return `${catId}.${subId}` }
function toggleMain(catId: string) { setOpenMain(prev => ({ ...prev, [catId]: !prev[catId] })) }
function toggleSub(catId: string, subId: string) { const k = keyFor(catId, subId); setOpenSub(prev => ({ ...prev, [k]: !prev[k] })) }
// Category actions
async function createCategory() {
if (!newCat.name) return
try {
const id = slugify(newCat.name)
await api.post('/skills/categories', { id, name: newCat.name })
setNewCat({ name: '' })
setAddingCat(false)
fetchHierarchy()
} catch { setError('Kategorie konnte nicht erstellt werden') }
}
function startEditCategory(cat: Category) { setEditingCat(cat.id); setEditCatName(cat.name) }
async function saveCategory(catId: string) {
try {
await api.put(`/skills/categories/${catId}`, { name: editCatName })
setEditingCat(null)
fetchHierarchy()
} catch { setError('Kategorie konnte nicht aktualisiert werden') }
}
async function deleteCategory(catId: string) {
if (!confirm('Kategorie und alle Inhalte löschen?')) return
try { await api.delete(`/skills/categories/${catId}`); fetchHierarchy() } catch { setError('Kategorie konnte nicht gelöscht werden') }
}
// Subcategory actions
function startAddSub(catId: string) { setAddingSub(prev => ({ ...prev, [catId]: true })); setNewSub(prev => ({ ...prev, [catId]: { name: '' } })) }
async function createSub(catId: string) {
const data = newSub[catId]
if (!data || !data.name) return
try {
const id = slugify(data.name)
await api.post(`/skills/categories/${catId}/subcategories`, { id, name: data.name })
setAddingSub(prev => ({ ...prev, [catId]: false }))
fetchHierarchy()
} catch { setError('Unterkategorie konnte nicht erstellt werden') }
}
function startEditSub(catId: string, sub: Subcategory) { setEditingSub(keyFor(catId, sub.id)); setEditSubName(sub.name) }
async function saveSub(catId: string, subId: string) {
try {
await api.put(`/skills/categories/${catId}/subcategories/${subId}`, { name: editSubName })
setEditingSub(null)
fetchHierarchy()
} catch { setError('Unterkategorie konnte nicht aktualisiert werden') }
}
async function deleteSub(catId: string, subId: string) {
if (!confirm('Unterkategorie und enthaltene Skills löschen?')) return
try { await api.delete(`/skills/categories/${catId}/subcategories/${subId}`); fetchHierarchy() } catch { setError('Unterkategorie konnte nicht gelöscht werden') }
}
// Skill actions
function startAddSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
setAddingSkill(prev => ({ ...prev, [k]: true }))
setNewSkill(prev => ({ ...prev, [k]: { name: '', description: '' } }))
}
async function createSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
const data = newSkill[k]
if (!data || !data.name) return
try {
const id = slugify(data.name)
await api.post('/skills', { id, name: data.name, category: `${catId}.${subId}`, description: data.description || null })
setAddingSkill(prev => ({ ...prev, [k]: false }))
fetchHierarchy()
} catch { setError('Skill konnte nicht erstellt werden') }
}
function startEditSkill(skill: Skill) { setEditingSkill(skill.id); setEditSkillData({ name: skill.name, description: skill.description || '' }) }
async function saveSkill(id: string) {
try {
await api.put(`/skills/${id}`, { name: editSkillData.name, description: editSkillData.description ?? null })
setEditingSkill(null)
fetchHierarchy()
} catch { setError('Skill konnte nicht aktualisiert werden') }
}
async function deleteSkill(id: string) {
if (!confirm('Skill löschen?')) return
try { await api.delete(`/skills/${id}`); fetchHierarchy() } catch { setError('Skill konnte nicht gelöscht werden') }
}
return (
<div>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">Skill-Verwaltung</h1>
<p className="text-body text-secondary">Kategorien und Unterkategorien wie im Frontend (ohne Niveaus)</p>
</div>
<div>
{!addingCat ? (
<button className="btn-primary" onClick={() => setAddingCat(true)}>+ Kategorie</button>
) : (
<div className="flex items-center gap-2">
<input className="input-field" placeholder="Name der Kategorie (z. B. Technische Fähigkeiten)" value={newCat.name} onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
<button className="btn-primary" onClick={createCategory}>Speichern</button>
<button className="btn-secondary" onClick={() => { setAddingCat(false); setNewCat({ name: '' }) }}>Abbrechen</button>
</div>
)}
</div>
</div>
{error && <div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>}
{loading ? (
<div className="text-secondary">Lade Skills...</div>
) : (
hierarchy.map((cat) => (
<div key={cat.id} className="card mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="btn-secondary h-8 px-3" onClick={() => toggleMain(cat.id)}>{openMain[cat.id] ? '' : '+'}</button>
{editingCat === cat.id ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveCategory(cat.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingCat(null)}></button>
</div>
) : (
<h3 className="text-title-card font-semibold text-primary">{cat.name}</h3>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditCategory(cat)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSub(cat.id)}>+ Unterkategorie</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteCategory(cat.id)}>Löschen</button>
</div>
</div>
{addingSub[cat.id] && (
<div className="mt-3 flex items-center gap-2">
<input className="input-field" placeholder="Name der Unterkategorie (z. B. Programmierung)" value={newSub[cat.id]?.name || ''} onChange={(e) => setNewSub(prev => ({ ...prev, [cat.id]: { ...(prev[cat.id] || { name: '' }), name: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSub(cat.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSub(prev => ({ ...prev, [cat.id]: false }))}>Abbrechen</button>
</div>
)}
{openMain[cat.id] && (
<div className="mt-3">
{cat.subcategories.map((sub) => (
<div key={`${cat.id}.${sub.id}`} className="mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="btn-secondary h-8 px-3" onClick={() => toggleSub(cat.id, sub.id)}>{openSub[keyFor(cat.id, sub.id)] ? '' : '+'}</button>
{editingSub === keyFor(cat.id, sub.id) ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editSubName} onChange={(e) => setEditSubName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveSub(cat.id, sub.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSub(null)}></button>
</div>
) : (
<div className="font-medium text-secondary">{sub.name}</div>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditSub(cat.id, sub)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSkill(cat.id, sub.id)}>+ Skill</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSub(cat.id, sub.id)}>Löschen</button>
</div>
</div>
{addingSkill[keyFor(cat.id, sub.id)] && (
<div className="mt-2 flex items-center gap-2">
<input className="input-field" placeholder="Skill-Name (z. B. Python)" value={newSkill[keyFor(cat.id, sub.id)]?.name || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), name: e.target.value } }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={newSkill[keyFor(cat.id, sub.id)]?.description || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), description: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSkill(cat.id, sub.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: false }))}>Abbrechen</button>
</div>
)}
{openSub[keyFor(cat.id, sub.id)] && (
<div className="divide-y divide-border-default mt-2">
{sub.skills.map((sk) => (
<div key={sk.id} className="py-2 flex items-center justify-between">
{editingSkill === sk.id ? (
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
<input className="input-field" value={editSkillData.name} onChange={(e) => setEditSkillData(prev => ({ ...prev, name: e.target.value }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={editSkillData.description || ''} onChange={(e) => setEditSkillData(prev => ({ ...prev, description: e.target.value }))} />
</div>
) : (
<div className="flex-1 pr-4">
<div className="font-medium text-primary">{sk.name}</div>
{sk.description && <div className="text-small text-tertiary">{sk.description}</div>}
</div>
)}
<div className="flex items-center gap-2">
{editingSkill === sk.id ? (
<>
<button className="btn-primary h-8 px-3" onClick={() => saveSkill(sk.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSkill(null)}></button>
</>
) : (
<>
<input type="checkbox" className="w-4 h-4 mr-2" checked readOnly aria-label="Aktiv" />
<button className="btn-secondary h-8 px-3" onClick={() => startEditSkill(sk)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSkill(sk.id)}>Löschen</button>
</>
)}
</div>
</div>
))}
{sub.skills.length === 0 && (
<div className="py-2 text-small text-tertiary">Keine Skills in dieser Unterkategorie</div>
)}
</div>
)}
</div>
))}
{cat.subcategories.length === 0 && (
<div className="text-small text-tertiary mt-2">Keine Unterkategorien</div>
)}
</div>
)}
</div>
))
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,517 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Edit2, Save, X, RefreshCw, Monitor, Globe } from 'lucide-react'
import { networkApi, NetworkNode, SyncSettings as SyncSettingsType } from '../services/networkApi'
export default function SyncSettings() {
const [nodes, setNodes] = useState<NetworkNode[]>([])
const [editingNode, setEditingNode] = useState<string | null>(null)
const [newNode, setNewNode] = useState<Partial<NetworkNode>>({
name: '',
location: '',
ipAddress: '',
port: 3005,
apiKey: '',
type: 'local'
})
const [showNewNodeForm, setShowNewNodeForm] = useState(false)
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle')
const [syncSettings, setSyncSettings] = useState<SyncSettingsType>({
autoSyncInterval: 'disabled',
conflictResolution: 'admin',
syncEmployees: true,
syncSkills: true,
syncUsers: true,
syncSettings: false,
bandwidthLimit: null
})
useEffect(() => {
// Load nodes and settings from backend
fetchNodes()
fetchSyncSettings()
}, [])
const fetchNodes = async () => {
try {
const data = await networkApi.getNodes()
setNodes(data)
} catch (error) {
console.error('Failed to fetch nodes:', error)
}
}
const fetchSyncSettings = async () => {
try {
const data = await networkApi.getSyncSettings()
setSyncSettings(data)
} catch (error) {
console.error('Failed to fetch sync settings:', error)
}
}
const handleAddNode = async () => {
try {
const result = await networkApi.createNode({
name: newNode.name || '',
location: newNode.location || '',
ipAddress: newNode.ipAddress || '',
port: newNode.port || 3005,
type: newNode.type as 'admin' | 'local'
})
// Refresh nodes list
await fetchNodes()
setNewNode({
name: '',
location: '',
ipAddress: '',
port: 3005,
apiKey: '',
type: 'local'
})
setShowNewNodeForm(false)
// Show API key to user
alert(`Node created successfully!\n\nAPI Key: ${result.apiKey}\n\nPlease save this key securely. It won't be shown again.`)
} catch (error) {
console.error('Failed to add node:', error)
alert('Failed to add node')
}
}
const handleUpdateNode = async (nodeId: string, updates: Partial<NetworkNode>) => {
try {
await networkApi.updateNode(nodeId, updates)
setNodes(nodes.map(node =>
node.id === nodeId ? { ...node, ...updates } : node
))
setEditingNode(null)
} catch (error) {
console.error('Failed to update node:', error)
alert('Failed to update node')
}
}
const handleDeleteNode = async (nodeId: string) => {
if (!confirm('Sind Sie sicher, dass Sie diesen Knoten löschen möchten?')) {
return
}
try {
await networkApi.deleteNode(nodeId)
setNodes(nodes.filter(node => node.id !== nodeId))
} catch (error) {
console.error('Failed to delete node:', error)
alert('Failed to delete node')
}
}
const handleSyncAll = async () => {
setSyncStatus('syncing')
try {
await networkApi.triggerSync()
setSyncStatus('success')
setTimeout(() => setSyncStatus('idle'), 3000)
// Refresh nodes to update sync status
fetchNodes()
} catch (error) {
setSyncStatus('error')
setTimeout(() => setSyncStatus('idle'), 3000)
}
}
const handleSaveSyncSettings = async () => {
try {
await networkApi.updateSyncSettings(syncSettings)
alert('Sync settings saved successfully')
} catch (error) {
console.error('Failed to save sync settings:', error)
alert('Failed to save sync settings')
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Netzwerk & Synchronisation
</h1>
<div className="flex space-x-4">
<button
onClick={handleSyncAll}
disabled={syncStatus === 'syncing'}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
syncStatus === 'syncing'
? 'bg-gray-300 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
<RefreshCw className={`w-5 h-5 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`} />
<span>
{syncStatus === 'syncing' ? 'Synchronisiere...' :
syncStatus === 'success' ? 'Erfolgreich!' :
syncStatus === 'error' ? 'Fehler!' : 'Alle synchronisieren'}
</span>
</button>
<button
onClick={() => setShowNewNodeForm(true)}
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
<span>Knoten hinzufügen</span>
</button>
</div>
</div>
{/* Network Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<Globe className="w-8 h-8 text-blue-600" />
<span className="text-2xl font-bold text-gray-900">{nodes.length}</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Gesamte Knoten</h3>
<p className="text-sm text-gray-500">Im Netzwerk</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<Monitor className="w-8 h-8 text-green-600" />
<span className="text-2xl font-bold text-gray-900">
{nodes.filter(n => n.isOnline).length}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Online</h3>
<p className="text-sm text-gray-500">Aktive Verbindungen</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<RefreshCw className="w-8 h-8 text-blue-600" />
<span className="text-sm font-medium text-gray-900">
{nodes[0]?.lastSync ? new Date(nodes[0].lastSync).toLocaleString('de-DE') : 'Nie'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Letzte Sync</h3>
<p className="text-sm text-gray-500">Admin-Knoten</p>
</div>
</div>
{/* New Node Form */}
{showNewNodeForm && (
<div className="bg-white rounded-lg shadow mb-6 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Neuen Knoten hinzufügen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={newNode.name || ''}
onChange={(e) => setNewNode({ ...newNode, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. Außenstelle Frankfurt"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Standort
</label>
<input
type="text"
value={newNode.location || ''}
onChange={(e) => setNewNode({ ...newNode, location: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. Frankfurt, Deutschland"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
IP-Adresse
</label>
<input
type="text"
value={newNode.ipAddress || ''}
onChange={(e) => setNewNode({ ...newNode, ipAddress: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. 192.168.1.100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port
</label>
<input
type="number"
value={newNode.port || 3005}
onChange={(e) => setNewNode({ ...newNode, port: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ
</label>
<select
value={newNode.type || 'local'}
onChange={(e) => setNewNode({ ...newNode, type: e.target.value as 'admin' | 'local' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="local">Lokaler Knoten</option>
<option value="admin">Admin Knoten</option>
</select>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowNewNodeForm(false)}
className="px-4 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleAddNode}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Hinzufügen
</button>
</div>
</div>
)}
{/* Nodes List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
Netzwerkknoten
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Standort
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP-Adresse
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Typ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Letzte Sync
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{nodes.map((node) => (
<tr key={node.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`w-3 h-3 rounded-full ${
node.isOnline ? 'bg-green-500' : 'bg-red-500'
}`} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingNode === node.id ? (
<input
type="text"
value={node.name}
onChange={(e) => handleUpdateNode(node.id, { name: e.target.value })}
className="px-2 py-1 border border-gray-300 rounded"
/>
) : (
<span className="text-sm font-medium text-gray-900">{node.name}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.location}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.ipAddress}:{node.port}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
node.type === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{node.type === 'admin' ? 'Admin' : 'Lokal'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.lastSync ? new Date(node.lastSync).toLocaleString('de-DE') : 'Nie'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingNode === node.id ? (
<>
<button
onClick={() => setEditingNode(null)}
className="text-green-600 hover:text-green-900"
>
<Save className="w-4 h-4" />
</button>
<button
onClick={() => setEditingNode(null)}
className="text-gray-600 hover:text-gray-900"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => setEditingNode(node.id)}
className="text-blue-600 hover:text-primary-900"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNode(node.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Sync Configuration */}
<div className="mt-8 bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Synchronisationseinstellungen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Automatische Synchronisation
</label>
<select
value={syncSettings.autoSyncInterval}
onChange={(e) => setSyncSettings({ ...syncSettings, autoSyncInterval: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="disabled">Deaktiviert</option>
<option value="5min">Alle 5 Minuten</option>
<option value="15min">Alle 15 Minuten</option>
<option value="30min">Alle 30 Minuten</option>
<option value="1hour">Jede Stunde</option>
<option value="daily">Täglich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konfliktauflösung
</label>
<select
value={syncSettings.conflictResolution}
onChange={(e) => setSyncSettings({ ...syncSettings, conflictResolution: e.target.value as 'admin' | 'newest' | 'manual' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="admin">Admin hat Vorrang</option>
<option value="newest">Neueste Änderung gewinnt</option>
<option value="manual">Manuell auflösen</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Daten-Synchronisation
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncEmployees}
onChange={(e) => setSyncSettings({ ...syncSettings, syncEmployees: e.target.checked })}
/>
<span className="text-sm">Mitarbeiterdaten</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncSkills}
onChange={(e) => setSyncSettings({ ...syncSettings, syncSkills: e.target.checked })}
/>
<span className="text-sm">Skills und Qualifikationen</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncUsers}
onChange={(e) => setSyncSettings({ ...syncSettings, syncUsers: e.target.checked })}
/>
<span className="text-sm">Benutzerkonten</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncSettings}
onChange={(e) => setSyncSettings({ ...syncSettings, syncSettings: e.target.checked })}
/>
<span className="text-sm">Systemeinstellungen</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bandbreitenbegrenzung
</label>
<div className="flex items-center space-x-3">
<input
type="number"
placeholder="Unbegrenzt"
value={syncSettings.bandwidthLimit || ''}
onChange={(e) => setSyncSettings({ ...syncSettings, bandwidthLimit: e.target.value ? parseInt(e.target.value) : null })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-sm text-gray-500">KB/s</span>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleSaveSyncSettings}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Einstellungen speichern
</button>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,658 @@
import { useState, useEffect, DragEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../services/api'
import { User, UserRole } from '@skillmate/shared'
import { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
import { useAuthStore } from '../stores/authStore'
interface UserWithEmployee extends User {
employeeName?: string
}
export default function UserManagement() {
const navigate = useNavigate()
const { user: currentUser } = useAuthStore()
const [users, setUsers] = useState<UserWithEmployee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editingUser, setEditingUser] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('user')
const [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
setLoading(true)
const [usersResponse, employeesResponse] = await Promise.all([
api.get('/admin/users'),
api.get('/employees')
])
const usersData = usersResponse.data.data || []
const employeesData = employeesResponse.data.data || []
// Match users with employee names
const enrichedUsers = usersData.map((user: User) => {
const employee = employeesData.find((emp: any) => emp.id === user.employeeId)
return {
...user,
employeeName: employee ? `${employee.firstName} ${employee.lastName}` : undefined
}
})
setUsers(enrichedUsers)
setEmployees(employeesData)
} catch (err: any) {
console.error('Failed to fetch users:', err)
setError('Benutzer konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const [employees, setEmployees] = useState<any[]>([])
// Import state
type ImportRow = { firstName: string; lastName: string; email: string; department: string }
const [dragActive, setDragActive] = useState(false)
const [parsedRows, setParsedRows] = useState<ImportRow[]>([])
const [parseError, setParseError] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [importResults, setImportResults] = useState<{
index: number
status: 'created' | 'error'
employeeId?: string
userId?: string
username?: string
email?: string
temporaryPassword?: string
error?: string
}[]>([])
// Store temporary passwords per user to show + email
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({})
// removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => {
try {
await api.put(`/admin/users/${userId}/role`, { role: editRole })
await fetchUsers()
setEditingUser(null)
} catch (err: any) {
setError('Rolle konnte nicht geändert werden')
}
}
const handleToggleActive = async (userId: string, isActive: boolean) => {
try {
await api.put(`/admin/users/${userId}/status`, { isActive: !isActive })
await fetchUsers()
} catch (err: any) {
setError('Status konnte nicht geändert werden')
}
}
const handlePasswordReset = async (userId: string) => {
try {
const response = await api.post(`/admin/users/${userId}/reset-password`, {
newPassword: newPassword || undefined
})
const tempPassword = response.data.data?.temporaryPassword
if (tempPassword) {
setTempPasswords(prev => ({ ...prev, [userId]: { password: tempPassword } }))
}
setResetPasswordUser(null)
setNewPassword('')
} catch (err: any) {
setError('Passwort konnte nicht zurückgesetzt werden')
}
}
const sendTempPasswordEmail = async (userId: string, password: string) => {
try {
await api.post(`/admin/users/${userId}/send-temp-password`, { password })
alert('Temporäres Passwort per E-Mail versendet.')
} catch (err: any) {
setError('E-Mail-Versand des temporären Passworts fehlgeschlagen')
}
}
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(true)
}
const onDragLeave = () => setDragActive(false)
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0])
}
}
const handleFile = (file: File) => {
setParseError('')
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result || '')
if (file.name.toLowerCase().endsWith('.json')) {
const json = JSON.parse(text)
const rows: ImportRow[] = Array.isArray(json) ? json : [json]
validateAndSetRows(rows)
} else {
// Simple CSV parser (comma or semicolon)
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0)
if (lines.length === 0) throw new Error('Leere Datei')
const header = lines[0].split(/[;,]/).map(h => h.trim().toLowerCase())
const idx = (name: string) => header.indexOf(name)
const rows: ImportRow[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(/[;,]/).map(c => c.trim())
rows.push({
firstName: cols[idx('firstname')] || cols[idx('vorname')] || '',
lastName: cols[idx('lastname')] || cols[idx('nachname')] || '',
email: cols[idx('email')] || '',
department: cols[idx('department')] || cols[idx('abteilung')] || ''
})
}
validateAndSetRows(rows)
}
} catch (err: any) {
setParseError(err.message || 'Datei konnte nicht gelesen werden')
setParsedRows([])
}
}
reader.readAsText(file)
}
const validateAndSetRows = (rows: ImportRow[]) => {
const valid: ImportRow[] = []
for (const r of rows) {
if (!r.firstName || !r.lastName || !r.email || !r.department) continue
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email)) continue
valid.push({ ...r })
}
setParsedRows(valid)
}
const startImport = async () => {
if (parsedRows.length === 0) return
setIsImporting(true)
setImportResults([])
try {
const results: any[] = []
for (let i = 0; i < parsedRows.length; i++) {
const r = parsedRows[i]
try {
const res = await api.post('/employees', {
firstName: r.firstName,
lastName: r.lastName,
email: r.email,
department: r.department,
createUser: true,
userRole: 'user'
})
const data = res.data?.data || {}
results.push({
index: i,
status: 'created',
employeeId: data.id,
userId: data.userId,
email: r.email,
username: r.email?.split('@')[0],
temporaryPassword: data.temporaryPassword
})
} catch (err: any) {
results.push({ index: i, status: 'error', error: err.response?.data?.error?.message || 'Fehler beim Import' })
}
}
setImportResults(results)
await fetchUsers()
} finally {
setIsImporting(false)
}
}
const handleDeleteUser = async (userId: string) => {
if (!confirm('Möchten Sie diesen Benutzer wirklich löschen?')) return
try {
await api.delete(`/admin/users/${userId}`)
await fetchUsers()
} catch (err: any) {
setError('Benutzer konnte nicht gelöscht werden')
}
}
const getRoleBadgeColor = (role: UserRole) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'superuser':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getRoleLabel = (role: UserRole) => {
switch (role) {
case 'admin':
return 'Administrator'
case 'superuser':
return 'Poweruser'
default:
return 'Benutzer'
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Benutzer...</div>
</div>
)
}
return (
<div>
<div className="mb-8 flex justify-between items-start">
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">
Benutzerverwaltung
</h1>
<p className="text-body text-secondary">
Verwalten Sie Benutzerkonten, Rollen und Zugriffsrechte
</p>
</div>
<button
onClick={() => navigate('/users/create-employee')}
className="btn-primary"
>
+ Neuen Mitarbeiter anlegen
</button>
</div>
{currentUser?.role === 'admin' && (
<div className="card mb-6 bg-red-50 border border-red-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-2">Administrative Aktionen</h3>
<PurgeUsersPanel onDone={fetchUsers} />
</div>
)}
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Benutzer
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Mitarbeiter
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Rolle
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Status
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Letzter Login
</th>
<th className="text-right py-3 px-4 font-poppins font-medium text-secondary">
Aktionen
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border-light hover:bg-bg-hover">
<td className="py-4 px-4">
<div className="flex items-center">
<div className="w-8 h-8 bg-primary-blue rounded-full flex items-center justify-center text-white text-sm font-medium mr-3">
{user.username === 'admin' ? 'A' : user.username.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-primary">
{user.username === 'admin' ? 'admin' : user.username}
</span>
</div>
</td>
<td className="py-4 px-4">
{user.employeeName ? (
<span className="text-secondary">{user.employeeName}</span>
) : (
<span className="text-tertiary italic">Nicht verknüpft</span>
)}
</td>
<td className="py-4 px-4">
{editingUser === user.id ? (
<div className="flex items-center space-x-2">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="input-field py-1 text-sm"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<button
onClick={() => handleRoleChange(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => setEditingUser(null)}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)}
</span>
)}
</td>
<td className="py-4 px-4">
<button
onClick={() => handleToggleActive(user.id, user.isActive)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="py-4 px-4 text-secondary text-sm">
{user.lastLogin ? new Date(user.lastLogin).toLocaleString('de-DE') : 'Nie'}
</td>
<td className="py-4 px-4">
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setEditingUser(user.id)
setEditRole(user.role)
}}
className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten"
>
<ShieldIcon className="w-5 h-5" />
</button>
{resetPasswordUser === user.id ? (
<div className="flex items-center space-x-2">
<input
type="text"
placeholder="Neues Passwort (leer = zufällig)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="input-field py-1 text-sm w-40"
/>
<button
onClick={() => handlePasswordReset(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<button
onClick={() => setResetPasswordUser(user.id)}
className="p-1 text-secondary hover:text-warning transition-colors"
title="Passwort zurücksetzen"
>
<KeyIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1 text-secondary hover:text-error transition-colors"
title="Benutzer löschen"
disabled={user.username === 'admin'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
{tempPasswords[user.id] && (
<div className="mt-2 bg-green-50 border border-green-200 rounded-input p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-green-800">
Temporäres Passwort: <code className="bg-gray-100 px-2 py-0.5 rounded">{tempPasswords[user.id].password}</code>
</div>
<div className="flex gap-2">
<button
className="btn-secondary h-8 px-3"
onClick={() => navigator.clipboard.writeText(tempPasswords[user.id].password)}
>
Kopieren
</button>
<button
className="btn-primary h-8 px-3"
onClick={() => sendTempPasswordEmail(user.id, tempPasswords[user.id].password)}
>
E-Mail senden
</button>
</div>
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-8 text-secondary">
Keine Benutzer gefunden
</div>
)}
</div>
</div>
<div className="mt-6 card bg-blue-50 border-blue-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Hinweise zur Benutzerverwaltung
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li>
<li> <strong>Poweruser:</strong> Kann Mitarbeiter und Skills verwalten, aber keine Systemeinstellungen ändern</li>
<li> <strong>Benutzer:</strong> Kann nur eigenes Profil bearbeiten und Daten einsehen</li>
<li> Neue Benutzer können über den Import oder die Mitarbeiterverwaltung angelegt werden</li>
<li> Der Admin-Benutzer kann nicht gelöscht werden</li>
</ul>
</div>
<div className="mt-8 card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Import neue Nutzer (CSV oder JSON)
</h3>
<div
className={`border-2 border-dashed rounded-input p-6 text-center cursor-pointer ${dragActive ? 'border-primary-blue' : 'border-border-default'}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<p className="text-secondary mb-2">Datei hierher ziehen oder auswählen</p>
<input
type="file"
accept=".csv,.json,application/json,text/csv"
onChange={(e) => e.target.files && e.target.files[0] && handleFile(e.target.files[0])}
className="hidden"
id="user-import-input"
/>
<label htmlFor="user-import-input" className="btn-secondary inline-block">Datei auswählen</label>
{parseError && <div className="text-error text-sm mt-3">{parseError}</div>}
</div>
<div className="mt-4 text-body text-secondary">
<p className="mb-2 font-medium">Eingabekonventionen:</p>
<ul className="list-disc ml-5 space-y-1">
<li>CSV mit Kopfzeile: firstName;lastName;email;department (Komma oder Semikolon)</li>
<li>JSON: Array von Objekten mit Schlüsseln firstName, lastName, email, department</li>
<li>E-Mail muss valide sein; Rolle wird initial immer user</li>
<li>Es wird stets ein temporäres Passwort erzeugt; Anzeige nach Import</li>
</ul>
</div>
{parsedRows.length > 0 && (
<div className="mt-4">
<h4 className="text-body font-semibold text-secondary mb-2">Vorschau ({parsedRows.length} Einträge)</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Vorname</th>
<th className="text-left py-2 px-3 text-secondary">Nachname</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Abteilung</th>
</tr>
</thead>
<tbody>
{parsedRows.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.firstName}</td>
<td className="py-2 px-3">{r.lastName}</td>
<td className="py-2 px-3">{r.email}</td>
<td className="py-2 px-3">{r.department}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end">
<button className="btn-primary" onClick={startImport} disabled={isImporting}>
{isImporting ? 'Importiere...' : 'Import starten'}
</button>
</div>
</div>
)}
{importResults.length > 0 && (
<div className="mt-6">
<h4 className="text-body font-semibold text-secondary mb-2">Import-Ergebnis</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Zeile</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Status</th>
<th className="text-left py-2 px-3 text-secondary">Temporäres Passwort</th>
<th className="text-right py-2 px-3 text-secondary">Aktionen</th>
</tr>
</thead>
<tbody>
{importResults.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.index + 1}</td>
<td className="py-2 px-3">{r.email || '—'}</td>
<td className="py-2 px-3">{r.status === 'created' ? 'Erstellt' : `Fehler: ${r.error}`}</td>
<td className="py-2 px-3">
{r.temporaryPassword ? (
<code className="bg-gray-100 px-2 py-1 rounded">{r.temporaryPassword}</code>
) : (
'—'
)}
</td>
<td className="py-2 px-3 text-right">
{r.userId && r.temporaryPassword && (
<div className="flex justify-end gap-2">
<button
className="btn-secondary h-9 px-3"
onClick={() => navigator.clipboard.writeText(r.temporaryPassword!)}
>
Kopieren
</button>
<button
className="btn-primary h-9 px-3"
onClick={() => sendTempPasswordEmail(r.userId!, r.temporaryPassword!)}
>
E-Mail senden
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}
function PurgeUsersPanel({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('hendrik.gebhardt@polizei.nrw.de')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const runPurge = async () => {
if (!email || !email.includes('@')) {
setMsg('Bitte gültige E-Mail eingeben')
return
}
const ok = confirm(`Achtung: Dies löscht alle Benutzer außer 'admin' und ${email}. Fortfahren?`)
if (!ok) return
try {
setBusy(true)
setMsg('')
const res = await api.post('/admin/users/purge', { email })
const { kept, deleted } = res.data.data || {}
setMsg(`Bereinigung abgeschlossen. Behalten: ${kept}, gelöscht: ${deleted}`)
onDone()
} catch (e: any) {
setMsg('Bereinigung fehlgeschlagen')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-3">
<p className="text-body text-secondary">Nur Administratoren: Löscht alle Benutzer und behält nur 'admin' und die angegebene EMail.</p>
<div className="flex items-center gap-3">
<input
className="input-field w-80"
placeholder="EMail, die erhalten bleiben soll"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button className="btn-danger" onClick={runPurge} disabled={busy}>
Bereinigen
</button>
</div>
{msg && <div className="text-secondary">{msg}</div>}
</div>
)
}

107
admin-panel/tailwind.config.js Normale Datei
Datei anzeigen

@ -0,0 +1,107 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Light Mode Colors
'primary-blue': '#3182CE',
'primary-blue-hover': '#2563EB',
'primary-blue-active': '#1D4ED8',
'primary-blue-dark': '#1E40AF',
'bg-main': '#F8FAFC',
'bg-white': '#FFFFFF',
'bg-gray': '#F0F4F8',
'bg-accent': '#E6F2FF',
'text-primary': '#1A365D',
'text-secondary': '#2D3748',
'text-tertiary': '#4A5568',
'text-quaternary': '#718096',
'text-placeholder': '#A0AEC0',
'border-default': '#E2E8F0',
'border-input': '#CBD5E0',
'divider': '#F1F5F9',
'success': '#059669',
'success-bg': '#D1FAE5',
'warning': '#D97706',
'warning-bg': '#FEF3C7',
'error': '#DC2626',
'error-bg': '#FEE2E2',
'info': '#2563EB',
'info-bg': '#DBEAFE',
// Dark Mode Colors
'dark': {
'primary': '#232D53',
'accent': '#00D4FF',
'accent-hover': '#00B8E6',
'bg': '#000000',
'bg-secondary': '#1A1F3A',
'bg-sidebar': '#0A0A0A',
'bg-hover': '#232D53',
'bg-focus': '#2A3560',
'text-primary': '#FFFFFF',
'text-secondary': 'rgba(255, 255, 255, 0.7)',
'text-tertiary': 'rgba(255, 255, 255, 0.6)',
'border': 'rgba(255, 255, 255, 0.1)',
'success': '#4CAF50',
'warning': '#FFC107',
'error': '#FF4444',
'info': '#2196F3',
}
},
fontFamily: {
'poppins': ['Poppins', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
'sans': ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Arial', 'sans-serif'],
'mono': ['SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'monospace'],
},
fontSize: {
'title-lg': '32px',
'title-dialog': '24px',
'title-card': '20px',
'nav': '15px',
'body': '14px',
'small': '13px',
'help': '12px',
},
spacing: {
'container': '40px',
'card': '32px',
'element': '16px',
'inline': '8px',
},
borderRadius: {
'card': '16px',
'button': '24px',
'input': '8px',
'badge': '12px',
},
boxShadow: {
'sm': '0 1px 2px rgba(0, 0, 0, 0.05)',
'md': '0 4px 6px rgba(0, 0, 0, 0.1)',
'lg': '0 10px 15px rgba(0, 0, 0, 0.1)',
'xl': '0 20px 25px rgba(0, 0, 0, 0.1)',
'focus': '0 0 0 3px rgba(49, 130, 206, 0.1)',
'dark-sm': '0 2px 4px rgba(0, 0, 0, 0.3)',
'dark-md': '0 4px 12px rgba(0, 0, 0, 0.4)',
'dark-lg': '0 8px 24px rgba(0, 0, 0, 0.5)',
'dark-glow': '0 0 20px rgba(0, 212, 255, 0.3)',
},
transitionProperty: {
'all': 'all',
},
transitionDuration: {
'default': '300ms',
'fast': '200ms',
},
transitionTimingFunction: {
'default': 'ease',
},
},
},
plugins: [],
}

24
admin-panel/tsconfig.json Normale Datei
Datei anzeigen

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

Datei anzeigen

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
admin-panel/vite.config.ts Normale Datei
Datei anzeigen

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
server: {
port: 3006,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})