Initial commit
Dieser Commit ist enthalten in:
13
admin-panel/index.html
Normale Datei
13
admin-panel/index.html
Normale Datei
@ -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
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
31
admin-panel/package.json
Normale Datei
@ -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"
|
||||
}
|
||||
}
|
||||
6
admin-panel/postcss.config.js
Normale Datei
6
admin-panel/postcss.config.js
Normale Datei
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
49
admin-panel/src/App.tsx
Normale Datei
49
admin-panel/src/App.tsx
Normale Datei
@ -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
|
||||
7
admin-panel/src/components/HomeIcon.tsx
Normale Datei
7
admin-panel/src/components/HomeIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
85
admin-panel/src/components/Layout.tsx
Normale Datei
85
admin-panel/src/components/Layout.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/MoonIcon.tsx
Normale Datei
7
admin-panel/src/components/MoonIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/SearchIcon.tsx
Normale Datei
7
admin-panel/src/components/SearchIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
8
admin-panel/src/components/SettingsIcon.tsx
Normale Datei
8
admin-panel/src/components/SettingsIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/SunIcon.tsx
Normale Datei
7
admin-panel/src/components/SunIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
130
admin-panel/src/components/SyncStatus.tsx
Normale Datei
130
admin-panel/src/components/SyncStatus.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/UsersIcon.tsx
Normale Datei
7
admin-panel/src/components/UsersIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
110
admin-panel/src/components/icons.tsx
Normale Datei
110
admin-panel/src/components/icons.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
6
admin-panel/src/components/index.ts
Normale Datei
6
admin-panel/src/components/index.ts
Normale Datei
@ -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
198
admin-panel/src/index.css
Normale Datei
@ -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
10
admin-panel/src/main.tsx
Normale Datei
@ -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>,
|
||||
)
|
||||
39
admin-panel/src/services/api.ts
Normale Datei
39
admin-panel/src/services/api.ts
Normale Datei
@ -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
|
||||
66
admin-panel/src/services/networkApi.ts
Normale Datei
66
admin-panel/src/services/networkApi.ts
Normale Datei
@ -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
|
||||
}
|
||||
}
|
||||
26
admin-panel/src/stores/authStore.ts
Normale Datei
26
admin-panel/src/stores/authStore.ts
Normale Datei
@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
21
admin-panel/src/stores/themeStore.ts
Normale Datei
21
admin-panel/src/stores/themeStore.ts
Normale Datei
@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
203
admin-panel/src/styles/index.css
Normale Datei
203
admin-panel/src/styles/index.css
Normale Datei
@ -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;
|
||||
}
|
||||
}
|
||||
281
admin-panel/src/views/CreateEmployee.tsx
Normale Datei
281
admin-panel/src/views/CreateEmployee.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
160
admin-panel/src/views/Dashboard.tsx
Normale Datei
160
admin-panel/src/views/Dashboard.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
157
admin-panel/src/views/EmailSettings.tsx
Normale Datei
157
admin-panel/src/views/EmailSettings.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
251
admin-panel/src/views/EmployeeForm.tsx
Normale Datei
251
admin-panel/src/views/EmployeeForm.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
398
admin-panel/src/views/EmployeeFormComplete.tsx
Normale Datei
398
admin-panel/src/views/EmployeeFormComplete.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
119
admin-panel/src/views/EmployeeManagement.tsx
Normale Datei
119
admin-panel/src/views/EmployeeManagement.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
99
admin-panel/src/views/Login.tsx
Normale Datei
99
admin-panel/src/views/Login.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
283
admin-panel/src/views/SkillManagement.tsx
Normale Datei
283
admin-panel/src/views/SkillManagement.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
517
admin-panel/src/views/SyncSettings.tsx
Normale Datei
517
admin-panel/src/views/SyncSettings.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
@ -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 E‑Mail.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="input-field w-80"
|
||||
placeholder="E‑Mail, 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
107
admin-panel/tailwind.config.js
Normale Datei
@ -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
24
admin-panel/tsconfig.json
Normale Datei
@ -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" }]
|
||||
}
|
||||
10
admin-panel/tsconfig.node.json
Normale Datei
10
admin-panel/tsconfig.node.json
Normale Datei
@ -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
19
admin-panel/vite.config.ts
Normale Datei
@ -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,
|
||||
},
|
||||
})
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren