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

Datei anzeigen

@ -0,0 +1,91 @@
{
"appId": "de.skillmate.app",
"productName": "SkillMate",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"dist/**/*",
"electron/**/*",
"node_modules/**/*",
"!**/*.ts",
"!**/*.map",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"!**/node_modules/*.d.ts",
"!**/node_modules/.bin"
],
"extraResources": [
{
"from": "../backend/dist",
"to": "backend",
"filter": ["**/*"]
},
{
"from": "../backend/node_modules",
"to": "backend/node_modules",
"filter": ["**/*"]
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "build/icon.ico",
"publisherName": "SkillMate Development",
"certificateSubjectName": "SkillMate Development",
"requestedExecutionLevel": "asInvoker"
},
"msi": {
"oneClick": false,
"perMachine": true,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"displayLanguageSelector": false,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"uninstallDisplayName": "SkillMate",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "SkillMate",
"runAfterFinish": true,
"menuCategory": true,
"description": "Mitarbeiter-Skills-Management für Sicherheitsbehörden",
"branding": "SkillMate - Professionelle Mitarbeiterverwaltung",
"vendor": "SkillMate Development",
"installerHeaderIcon": "build/icon.ico",
"ui": {
"chooseDirectory": true,
"images": {
"background": "build/installer-background.jpg",
"banner": "build/installer-banner.jpg"
}
}
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"displayLanguageSelector": false,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"uninstallDisplayName": "SkillMate",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "SkillMate",
"menuCategory": true,
"description": "Mitarbeiter-Skills-Management für Sicherheitsbehörden",
"language": "1031",
"multiLanguageInstaller": false,
"installerHeader": "build/installer-header.bmp",
"installerSidebar": "build/installer-sidebar.bmp"
},
"publish": null
}

210
frontend/electron/main.js Normale Datei
Datei anzeigen

@ -0,0 +1,210 @@
const { app, BrowserWindow, ipcMain, Menu, Tray } = require('electron')
const path = require('path')
const { spawn } = require('child_process')
const isDev = process.env.NODE_ENV === 'development' || (!app.isPackaged && !process.env.NODE_ENV)
let mainWindow
let tray
let backendProcess
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1200,
minHeight: 700,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '../public/icon.png'),
titleBarStyle: 'hiddenInset',
frame: process.platform !== 'win32',
backgroundColor: '#F8FAFC'
})
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
// In production, load the built files
const indexPath = path.join(__dirname, '../dist/index.html')
console.log('Loading:', indexPath)
mainWindow.loadFile(indexPath)
// DevTools nur öffnen wenn explizit gewünscht
// mainWindow.webContents.openDevTools()
// Log any errors
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Failed to load:', errorCode, errorDescription)
})
mainWindow.webContents.on('console-message', (event, level, message) => {
console.log('Console:', message)
})
// Warte bis Seite geladen ist
mainWindow.webContents.on('did-finish-load', () => {
console.log('Page loaded successfully')
})
}
mainWindow.on('closed', () => {
mainWindow = null
})
// Custom window controls for Windows
if (process.platform === 'win32') {
mainWindow.on('maximize', () => {
mainWindow.webContents.send('window-maximized')
})
mainWindow.on('unmaximize', () => {
mainWindow.webContents.send('window-unmaximized')
})
}
}
function createTray() {
// Tray-Funktion vorerst deaktiviert, da Icon fehlt
// TODO: Tray-Icon hinzufügen
return
tray = new Tray(path.join(__dirname, '../public/tray-icon.png'))
const contextMenu = Menu.buildFromTemplate([
{
label: 'SkillMate öffnen',
click: () => {
if (mainWindow) {
mainWindow.show()
} else {
createWindow()
}
}
},
{ type: 'separator' },
{
label: 'Beenden',
click: () => {
app.quit()
}
}
])
tray.setToolTip('SkillMate')
tray.setContextMenu(contextMenu)
tray.on('click', () => {
if (mainWindow) {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
} else {
createWindow()
}
})
}
function startBackend() {
// Backend nur im Development-Modus automatisch starten
// In Production wird es extern gestartet
return
if (!isDev) {
// In production, backend is bundled with the app
const backendPath = app.isPackaged
? path.join(process.resourcesPath, 'backend', 'index.js')
: path.join(__dirname, '../../backend/dist/index.js')
console.log('Starting backend from:', backendPath)
// Check if backend exists
const fs = require('fs')
if (!fs.existsSync(backendPath)) {
console.error('Backend not found at:', backendPath)
// Try alternative path
const altPath = path.join(__dirname, '../backend/index.js')
console.log('Trying alternative path:', altPath)
if (fs.existsSync(altPath)) {
backendPath = altPath
}
}
backendProcess = spawn('node', [backendPath], {
env: {
...process.env,
NODE_ENV: 'production',
PORT: '3001',
DATABASE_PATH: path.join(app.getPath('userData'), 'skillmate.db'),
LOG_PATH: path.join(app.getPath('userData'), 'logs')
},
stdio: ['pipe', 'pipe', 'pipe']
})
backendProcess.stdout.on('data', (data) => {
console.log(`Backend: ${data}`)
})
backendProcess.stderr.on('data', (data) => {
console.error(`Backend Error: ${data}`)
})
backendProcess.on('error', (error) => {
console.error('Failed to start backend:', error)
})
backendProcess.on('exit', (code) => {
console.log(`Backend exited with code ${code}`)
})
}
}
app.whenReady().then(() => {
createWindow()
createTray()
startBackend()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
if (backendProcess) {
backendProcess.kill()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
// IPC handlers
ipcMain.handle('app:minimize', () => {
mainWindow.minimize()
})
ipcMain.handle('app:maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
ipcMain.handle('app:close', () => {
mainWindow.close()
})
ipcMain.handle('app:getVersion', () => {
return app.getVersion()
})
ipcMain.handle('app:getPath', (event, name) => {
return app.getPath(name)
})

21
frontend/electron/preload.js Normale Datei
Datei anzeigen

@ -0,0 +1,21 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
minimize: () => ipcRenderer.invoke('app:minimize'),
maximize: () => ipcRenderer.invoke('app:maximize'),
close: () => ipcRenderer.invoke('app:close'),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
getPath: (name) => ipcRenderer.invoke('app:getPath', name),
onWindowMaximized: (callback) => {
ipcRenderer.on('window-maximized', callback)
},
onWindowUnmaximized: (callback) => {
ipcRenderer.on('window-unmaximized', callback)
},
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel)
}
})

Datei anzeigen

@ -0,0 +1,5 @@
// Inject process object for renderer
window.process = {
env: {},
platform: 'win32'
}

20
frontend/index-electron.html Normale Datei
Datei anzeigen

@ -0,0 +1,20 @@
<!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 - Mitarbeiter-Skills-Management</title>
<script>
// Define process for Electron renderer
window.process = {
env: {},
platform: 'win32'
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

13
frontend/index.html Normale Datei
Datei anzeigen

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

Datei anzeigen

@ -0,0 +1,79 @@
; SkillMate Inno Setup Script
; Erstellt professionelle Windows-Installer
#define MyAppName "SkillMate"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "SkillMate Development"
#define MyAppURL "https://skillmate.local"
#define MyAppExeName "SkillMate.exe"
[Setup]
AppId={{8B3F4D2A-1E5C-4A7B-9C3D-2F1A6E8B9D7C}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
LicenseFile=..\..\..\LICENSE.txt
OutputDir=..\..\dist
OutputBaseFilename=SkillMate-Setup-{#MyAppVersion}
SetupIconFile=..\build\icon.ico
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
VersionInfoVersion={#MyAppVersion}
VersionInfoCompany={#MyAppPublisher}
VersionInfoDescription=Mitarbeiter-Skills-Management für Sicherheitsbehörden
VersionInfoCopyright=Copyright (C) 2024 {#MyAppPublisher}
VersionInfoProductName={#MyAppName}
VersionInfoProductVersion={#MyAppVersion}
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checked
Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; OnlyBelowVersion: 6.1; Flags: checked
[Files]
Source: "..\dist\win-unpacked\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "..\..\backend\dist\*"; DestDir: "{app}\resources\backend"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "..\..\backend\node_modules\*"; DestDir: "{app}\resources\backend\node_modules"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Code]
function InitializeSetup(): Boolean;
var
ResultCode: Integer;
begin
// Check for .NET Framework or Visual C++ Redistributables if needed
Result := True;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
// Create necessary directories
ForceDirectories(ExpandConstant('{userappdata}\SkillMate\logs'));
ForceDirectories(ExpandConstant('{userappdata}\SkillMate\data'));
end;
end;
[UninstallDelete]
Type: filesandordirs; Name: "{userappdata}\SkillMate"

3406
frontend/package-lock.json generiert Normale Datei

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

31
frontend/package.json Normale Datei
Datei anzeigen

@ -0,0 +1,31 @@
{
"name": "@skillmate/frontend",
"version": "1.0.0",
"description": "SkillMate - Mitarbeiter-Skills-Management für Sicherheitsbehörden",
"author": "SkillMate Development",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@skillmate/shared": "file:../shared",
"axios": "^1.6.2",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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
frontend/postcss.config.js Normale Datei
Datei anzeigen

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

30
frontend/public/debug.html Normale Datei
Datei anzeigen

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Debug Test</title>
</head>
<body>
<h1>Debug Test</h1>
<div id="root">
<p>Wenn Sie diesen Text sehen, funktioniert HTML.</p>
</div>
<script>
console.log('HTML geladen');
// Test ob process definiert ist
try {
console.log('process:', typeof process);
} catch (e) {
console.log('process ist nicht definiert - das ist normal in Electron Renderer');
}
// Test React mount point
const root = document.getElementById('root');
if (root) {
console.log('Root element gefunden');
root.innerHTML += '<p>JavaScript funktioniert!</p>';
}
</script>
</body>
</html>

11
frontend/public/icon.svg Normale Datei
Datei anzeigen

@ -0,0 +1,11 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="32" fill="#3182CE"/>
<g transform="translate(48, 48)">
<circle cx="80" cy="40" r="24" fill="white" opacity="0.9"/>
<path d="M40 80 C40 60, 60 50, 80 50 C100 50, 120 60, 120 80 L120 120 C120 130, 110 140, 100 140 L60 140 C50 140, 40 130, 40 120 Z" fill="white" opacity="0.9"/>
<rect x="0" y="100" width="60" height="8" rx="4" fill="white" opacity="0.7"/>
<rect x="0" y="120" width="80" height="8" rx="4" fill="white" opacity="0.7"/>
<rect x="100" y="100" width="60" height="8" rx="4" fill="white" opacity="0.7"/>
<rect x="80" y="120" width="80" height="8" rx="4" fill="white" opacity="0.7"/>
</g>
</svg>

Nachher

Breite:  |  Höhe:  |  Größe: 767 B

120
frontend/src/App.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,120 @@
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useThemeStore } from './stores/themeStore'
import { useAuthStore } from './stores/authStore'
import { useEffect } from 'react'
import Layout from './components/Layout'
import Login from './views/Login'
import Dashboard from './views/Dashboard'
import EmployeeList from './views/EmployeeList'
import EmployeeDetail from './views/EmployeeDetail'
import EmployeeForm from './views/EmployeeForm'
import SkillSearch from './views/SkillSearch'
import TeamZusammenstellung from './views/TeamZusammenstellung'
// import ProfileSearch from './views/ProfileSearch'
// import ProfileEdit from './views/ProfileEdit'
import Settings from './views/Settings'
import MyProfile from './views/MyProfile'
function App() {
const { isDarkMode } = useThemeStore()
const { isAuthenticated } = useAuthStore()
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [isDarkMode])
return (
<Router>
<Routes>
<Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <Login />
} />
<Route path="/" element={
isAuthenticated ? (
<Layout>
<Dashboard />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/employees" element={
isAuthenticated ? (
<Layout>
<EmployeeList />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/employees/new" element={
isAuthenticated ? (
<Layout>
<EmployeeForm />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/employees/:id" element={
isAuthenticated ? (
<Layout>
<EmployeeDetail />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/search" element={
isAuthenticated ? (
<Layout>
<SkillSearch />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/team" element={
isAuthenticated ? (
<Layout>
<TeamZusammenstellung />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/profile" element={
isAuthenticated ? (
<Layout>
<MyProfile />
</Layout>
) : <Navigate to="/login" replace />
} />
{/* Temporär deaktiviert
<Route path="/profiles" element={
isAuthenticated ? (
<Layout>
<ProfileSearch />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/profile/new" element={
isAuthenticated ? (
<Layout>
<ProfileEdit />
</Layout>
) : <Navigate to="/login" replace />
} />
<Route path="/profile/:id/edit" element={
isAuthenticated ? (
<Layout>
<ProfileEdit />
</Layout>
) : <Navigate to="/login" replace />
} />
*/}
<Route path="/settings" element={
isAuthenticated ? (
<Layout>
<Settings />
</Layout>
) : <Navigate to="/login" replace />
} />
</Routes>
</Router>
)
}
export default App

Datei anzeigen

@ -0,0 +1,90 @@
import type { Employee } from '@skillmate/shared'
interface EmployeeCardProps {
employee: Employee
onClick: () => void
}
export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const photoSrc = employee.photo && employee.photo.startsWith('/uploads/')
? `${PUBLIC_BASE}${employee.photo}`
: employee.photo || ''
const getAvailabilityBadge = (status: Employee['availability']) => {
const badges = {
available: { class: 'badge-success', text: 'Verfügbar' },
parttime: { class: 'badge-info', text: 'Teilzeit' },
unavailable: { class: 'badge-error', text: 'Nicht verfügbar' },
busy: { class: 'badge-warning', text: 'Beschäftigt' },
away: { class: 'badge-info', text: 'Abwesend' },
vacation: { class: 'badge-info', text: 'Urlaub' },
sick: { class: 'badge-error', text: 'Krank' },
training: { class: 'badge-info', text: 'Fortbildung' },
operation: { class: 'badge-warning', text: 'Im Einsatz' },
}
return badges[status] || { class: 'badge', text: status }
}
const availability = getAvailabilityBadge(employee.availability)
return (
<div className="card" onClick={onClick}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-full bg-bg-accent dark:bg-dark-primary flex items-center justify-center overflow-hidden">
{photoSrc ? (
<img
src={photoSrc}
alt={`${employee.firstName} ${employee.lastName}`}
className="w-full h-full rounded-full object-cover"
/>
) : (
<span className="text-2xl font-semibold text-primary-blue dark:text-dark-accent">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</span>
)}
</div>
<div>
<h3 className="text-body font-poppins font-semibold text-primary">
{employee.firstName} {employee.lastName}
</h3>
{employee.employeeNumber && (
<p className="text-small text-tertiary">{employee.employeeNumber}</p>
)}
</div>
</div>
<span className={`badge ${availability.class}`}>
{availability.text}
</span>
</div>
<div className="space-y-2 mb-4">
<p className="text-body text-secondary">
<span className="font-medium">Position:</span> {employee.position}
</p>
<p className="text-body text-secondary">
<span className="font-medium">Dienststelle:</span> {employee.department}
</p>
</div>
{employee.specializations.length > 0 && (
<div>
<p className="text-small font-medium text-tertiary mb-2">Spezialisierungen:</p>
<div className="flex flex-wrap gap-2">
{employee.specializations.slice(0, 3).map((spec, index) => (
<span key={index} className="badge badge-info text-xs">
{spec}
</span>
))}
{employee.specializations.length > 3 && (
<span className="text-xs text-tertiary">
+{employee.specializations.length - 3} weitere
</span>
)}
</div>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,157 @@
import { useState } from 'react'
import { useThemeStore } from '../stores/themeStore'
import { SunIcon, MoonIcon } from './icons'
import { useAuthStore } from '../stores/authStore'
import { authApi } from '../services/api'
import { usePermissions } from '../hooks/usePermissions'
import WindowControls from './WindowControls'
export default function Header() {
const { isDarkMode, toggleTheme } = useThemeStore()
const { user, isAuthenticated, login, logout } = useAuthStore()
const { canAccessAdminPanel } = usePermissions()
const [showLogin, setShowLogin] = useState(false)
const [loginForm, setLoginForm] = useState({ username: '', password: '' })
const [loginError, setLoginError] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoginError('')
setLoginLoading(true)
try {
const response = await authApi.login(loginForm.username, loginForm.password)
login(response.user, response.token)
localStorage.setItem('token', response.token)
setShowLogin(false)
setLoginForm({ username: '', password: '' })
} catch (error: any) {
setLoginError(error.response?.data?.message || 'Login fehlgeschlagen')
} finally {
setLoginLoading(false)
}
}
const handleLogout = () => {
logout()
localStorage.removeItem('token')
}
return (
<header className="bg-tertiary border-b border-primary h-16 flex items-center justify-between px-container relative -webkit-app-region-drag">
<div className="flex items-center space-x-4">
<h2 className="text-title-dialog font-poppins font-semibold text-primary">
SkillMate
</h2>
{/* Login/Logout Section */}
<div className="flex items-center space-x-2 -webkit-app-region-no-drag">
{!isAuthenticated ? (
<div className="relative">
<button
onClick={() => setShowLogin(!showLogin)}
className="btn-secondary text-sm px-3 py-1 h-8"
>
Anmelden
</button>
{/* Login Dropdown */}
{showLogin && (
<div className="absolute top-full left-0 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4 w-64 z-50">
<form onSubmit={handleLogin} className="space-y-3">
<div>
<input
type="text"
placeholder="Benutzername"
value={loginForm.username}
onChange={(e) => setLoginForm(prev => ({ ...prev, username: e.target.value }))}
className="input-field w-full text-sm"
required
/>
</div>
<div>
<input
type="password"
placeholder="Passwort"
value={loginForm.password}
onChange={(e) => setLoginForm(prev => ({ ...prev, password: e.target.value }))}
className="input-field w-full text-sm"
required
/>
</div>
{loginError && (
<p className="text-red-600 text-xs">{loginError}</p>
)}
<div className="flex space-x-2">
<button
type="submit"
disabled={loginLoading}
className="btn-primary text-sm px-3 py-1 h-8 flex-1"
>
{loginLoading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
<button
type="button"
onClick={() => setShowLogin(false)}
className="btn-secondary text-sm px-3 py-1 h-8"
>
Abbrechen
</button>
</div>
</form>
</div>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-secondary text-sm">{user?.username}</span>
<div className="w-8 h-8 rounded-full bg-primary-blue text-white flex items-center justify-center font-semibold">
{user?.username.charAt(0).toUpperCase()}
</div>
{canAccessAdminPanel() && (
<a
href="http://localhost:3002"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-sm px-3 py-1 h-8"
>
Admin
</a>
)}
<button
onClick={handleLogout}
className="btn-secondary text-sm px-3 py-1 h-8"
>
Abmelden
</button>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-4">
{/* Theme Toggle Slider - moved to the right */}
<div className="flex items-center space-x-2 -webkit-app-region-no-drag">
<SunIcon className="w-4 h-4 text-gray-500" />
<button
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
isDarkMode ? 'bg-blue-600' : 'bg-gray-200'
}`}
aria-label="Theme umschalten"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${
isDarkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<MoonIcon className="w-4 h-4 text-gray-500" />
</div>
</div>
<WindowControls />
</header>
)
}

Datei anzeigen

@ -0,0 +1,21 @@
import { ReactNode } from 'react'
import Sidebar from './Sidebar'
import Header from './Header'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="flex h-screen bg-primary">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-container">
{children}
</main>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,144 @@
import { useState, useRef } from 'react'
interface PhotoPreviewProps {
currentPhoto?: string
onPhotoSelect: (file: File) => void
onPhotoRemove?: () => void
disabled?: boolean
}
export default function PhotoPreview({
currentPhoto,
onPhotoSelect,
onPhotoRemove,
disabled = false
}: PhotoPreviewProps) {
const [dragOver, setDragOver] = useState(false)
const [error, setError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (file: File) => {
setError('')
// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
setError('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)')
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
setError('Datei zu groß (max. 5MB)')
return
}
onPhotoSelect(file)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setDragOver(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (disabled) return
const files = e.dataTransfer.files
if (files.length > 0) {
handleFileSelect(files[0])
}
}
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
handleFileSelect(files[0])
}
}
const triggerFileInput = () => {
if (!disabled) {
fileInputRef.current?.click()
}
}
return (
<div className="space-y-4">
<div
className={`relative w-32 h-32 rounded-full border-2 border-dashed transition-colors ${
dragOver ? 'border-primary-blue bg-bg-accent' : 'border-border-default'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={triggerFileInput}
>
{currentPhoto ? (
<img
src={currentPhoto}
alt="Employee photo"
className="w-full h-full rounded-full object-cover"
/>
) : (
<div className="w-full h-full rounded-full bg-bg-accent dark:bg-dark-primary flex items-center justify-center">
<svg className="w-8 h-8 text-text-placeholder" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
disabled={disabled}
/>
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={triggerFileInput}
disabled={disabled}
className="btn-secondary text-xs"
>
Foto auswählen
</button>
{currentPhoto && onPhotoRemove && (
<button
type="button"
onClick={onPhotoRemove}
disabled={disabled}
className="btn-secondary text-xs text-error hover:bg-error-bg"
>
Entfernen
</button>
)}
</div>
{error && (
<p className="text-error text-xs">{error}</p>
)}
<p className="text-xs text-tertiary">
Drag & Drop oder klicken zum Auswählen
<br />
Max. 5MB, JPEG/PNG/GIF/WebP
</p>
</div>
)
}

Datei anzeigen

@ -0,0 +1,227 @@
import { useState, useRef } from 'react'
import { useAuthStore } from '../stores/authStore'
interface PhotoUploadProps {
employeeId?: string
currentPhoto?: string
onPhotoUpdate?: (photoUrl: string | null) => void
disabled?: boolean
}
export default function PhotoUpload({
employeeId,
currentPhoto,
onPhotoUpdate,
disabled = false
}: PhotoUploadProps) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { token } = useAuthStore()
const handleFileSelect = (file: File) => {
if (!employeeId) {
setError('Employee ID is required')
return
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
setError('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)')
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
setError('Datei zu groß (max. 5MB)')
return
}
uploadPhoto(file)
}
const uploadPhoto = async (file: File) => {
if (!token) {
setError('Not authenticated')
return
}
setUploading(true)
setError('')
try {
const formData = new FormData()
formData.append('photo', file)
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const response = await fetch(`${API_BASE_URL}/upload/employee-photo/${employeeId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Upload failed')
}
// Update photo URL
const base = (import.meta as any).env?.VITE_API_PUBLIC_URL || (API_BASE_URL.replace(/\/api$/, ''))
const photoUrl = `${base}${data.data.photoUrl}`
onPhotoUpdate?.(photoUrl)
} catch (err: any) {
setError(err.message || 'Upload failed')
} finally {
setUploading(false)
}
}
const deletePhoto = async () => {
if (!employeeId || !token) return
setUploading(true)
setError('')
try {
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const response = await fetch(`${API_BASE_URL}/upload/employee-photo/${employeeId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Delete failed')
}
onPhotoUpdate?.(null)
} catch (err: any) {
setError(err.message || 'Delete failed')
} finally {
setUploading(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (disabled || uploading) return
const files = e.dataTransfer.files
if (files.length > 0) {
handleFileSelect(files[0])
}
}
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
handleFileSelect(files[0])
}
}
const triggerFileInput = () => {
if (!disabled && !uploading) {
fileInputRef.current?.click()
}
}
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const displayPhoto = currentPhoto && currentPhoto.startsWith('/uploads/') ? `${PUBLIC_BASE}${currentPhoto}` : currentPhoto
return (
<div className="space-y-4">
<div
className={`relative w-32 h-32 rounded-full border-2 border-dashed transition-colors ${
dragOver ? 'border-primary-blue bg-bg-accent' : 'border-border-default'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={triggerFileInput}
>
{displayPhoto ? (
<img
src={displayPhoto}
alt="Employee photo"
className="w-full h-full rounded-full object-cover"
/>
) : (
<div className="w-full h-full rounded-full bg-bg-accent dark:bg-dark-primary flex items-center justify-center">
<svg className="w-8 h-8 text-text-placeholder" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
)}
{uploading && (
<div className="absolute inset-0 rounded-full bg-black bg-opacity-50 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
disabled={disabled || uploading}
/>
</div>
<div className="flex space-x-2">
<button
onClick={triggerFileInput}
disabled={disabled || uploading}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading...' : 'Photo auswählen'}
</button>
{currentPhoto && (
<button
onClick={deletePhoto}
disabled={disabled || uploading}
className="btn-secondary text-xs text-error hover:bg-error-bg"
>
Entfernen
</button>
)}
</div>
{error && (
<p className="text-error text-xs">{error}</p>
)}
<p className="text-xs text-tertiary">
Drag & Drop oder klicken zum Auswählen
<br />
Max. 5MB, JPEG/PNG/GIF/WebP
</p>
</div>
)
}

Datei anzeigen

@ -0,0 +1,55 @@
import { NavLink } from 'react-router-dom'
import {
HomeIcon,
UsersIcon,
SearchIcon,
SettingsIcon,
DeskIcon,
MapIcon,
ChartIcon
} from './icons'
import { useAuthStore } from '../stores/authStore'
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Mein Profil', href: '/profile', icon: UsersIcon },
{ name: 'Mitarbeiter', href: '/employees', icon: UsersIcon },
{ name: 'Skill-Suche', href: '/search', icon: SearchIcon },
{ name: 'Team-Zusammenstellung', href: '/team', icon: UsersIcon },
{ name: 'Einstellungen', href: '/settings', icon: SettingsIcon },
]
export default function Sidebar() {
const { user } = useAuthStore()
// Filter navigation items based on user role
const filteredNavigation = navigation.filter(item => {
if (!item.roles) return true
return item.roles.includes(user?.role || '')
})
return (
<div className="w-[260px] bg-secondary dark:bg-dark-bg-sidebar border-r border-primary">
<div className="p-5">
<h1 className="text-title-card font-poppins font-semibold text-primary">
SkillMate
</h1>
</div>
<nav className="px-5 space-y-1">
{filteredNavigation.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>
)
}

Datei anzeigen

@ -0,0 +1,77 @@
import React, { useCallback } from 'react'
interface SkillLevelBarProps {
value: number | ''
onChange: (value: number) => void
min?: number
max?: number
disabled?: boolean
showHelp?: boolean
}
function segmentColor(i: number) {
// Use darker, more contrast-friendly shades in dark mode
if (i <= 3) return 'bg-red-500 hover:bg-red-600 dark:bg-red-700 dark:hover:bg-red-600'
if (i <= 6) return 'bg-green-500 hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-600'
return 'bg-purple-500 hover:bg-purple-600 dark:bg-purple-700 dark:hover:bg-purple-600'
}
export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disabled = false, showHelp = true }: SkillLevelBarProps) {
const handleKey = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault()
const v = typeof value === 'number' ? value : min - 1
onChange(Math.min(max, v + 1))
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault()
const v = typeof value === 'number' ? value : min
onChange(Math.max(min, v - 1))
} else if (e.key === 'Home') {
e.preventDefault(); onChange(min)
} else if (e.key === 'End') {
e.preventDefault(); onChange(max)
}
}, [value, onChange, min, max, disabled])
const current = typeof value === 'number' ? value : 0
return (
<div className="space-y-2">
<div
className={`flex items-center gap-1 select-none ${disabled ? 'opacity-60' : ''}`}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current || undefined}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKey}
>
{Array.from({ length: max }, (_, idx) => idx + 1).map(i => {
const active = i <= current
return (
<button
key={i}
type="button"
className={`h-6 flex-1 rounded-sm transition-colors ${
active
? segmentColor(i)
: 'bg-border-default hover:bg-bg-gray dark:bg-dark-border/40 dark:hover:bg-dark-border/60'
} ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
aria-label={`Level ${i}`}
onClick={() => !disabled && onChange(i)}
/>
)
})}
<span className="ml-3 text-small text-secondary min-w-[2ch] text-right">{current || ''}</span>
</div>
{showHelp && (
<div className="text-small text-tertiary">
<span className="mr-4"><strong>1</strong> Anfänger</span>
<span className="mr-4"><strong>5</strong> Fortgeschritten</span>
<span><strong>10</strong> Experte</span>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,59 @@
import { useState, useEffect } from 'react'
export default function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.onWindowMaximized(() => setIsMaximized(true))
window.electronAPI.onWindowUnmaximized(() => setIsMaximized(false))
return () => {
window.electronAPI?.removeAllListeners('window-maximized')
window.electronAPI?.removeAllListeners('window-unmaximized')
}
}
}, [])
if (!window.electronAPI || process.platform !== 'win32') {
return null
}
return (
<div className="absolute top-0 right-0 flex h-8 -webkit-app-region-no-drag">
<button
onClick={() => window.electronAPI?.minimize()}
className="px-4 hover:bg-border-default dark:hover:bg-dark-border transition-colors"
aria-label="Minimize"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 12 12">
<rect y="5" width="12" height="2" />
</svg>
</button>
<button
onClick={() => window.electronAPI?.maximize()}
className="px-4 hover:bg-border-default dark:hover:bg-dark-border transition-colors"
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 12 12">
<path d="M2 0v2H0v10h10V10h2V0H2zm8 10H2V4h8v6zm0-8H4V2h6v6h0v-6z" />
</svg>
) : (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 12 12">
<rect width="12" height="12" strokeWidth="2" fill="none" stroke="currentColor" />
</svg>
)}
</button>
<button
onClick={() => window.electronAPI?.close()}
className="px-4 hover:bg-error-bg hover:text-error dark:hover:bg-dark-error transition-colors"
aria-label="Close"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 12 12">
<path d="M1.41 0L6 4.59 10.59 0 12 1.41 7.41 6 12 10.59 10.59 12 6 7.41 1.41 12 0 10.59 4.59 6 0 1.41 1.41 0z" />
</svg>
</button>
</div>
)
}

Datei anzeigen

@ -0,0 +1,18 @@
export function ChartIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
)
}

Datei anzeigen

@ -0,0 +1,18 @@
export function DeskIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)
}

Datei anzeigen

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

Datei anzeigen

@ -0,0 +1,18 @@
export function MapIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
)
}

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -0,0 +1,9 @@
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'
export { DeskIcon } from './DeskIcon'
export { MapIcon } from './MapIcon'
export { ChartIcon } from './ChartIcon'

Datei anzeigen

@ -0,0 +1,97 @@
import { UserRole } from '@skillmate/shared'
import { useAuthStore } from '../stores/authStore'
// Define role permissions directly to avoid import issues
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
admin: [
'admin:panel:access',
'users:create',
'users:read',
'users:update',
'users:delete',
'users:manage_roles',
'employees:create',
'employees:read',
'employees:update',
'employees:delete',
'profiles:create',
'profiles:read',
'profiles:update',
'profiles:delete'
],
superuser: [
'employees:create',
'employees:read',
'profiles:create',
'profiles:read',
'profiles:update_own'
],
user: [
'employees:read',
'profiles:read',
'profiles:update_own'
]
}
export function usePermissions() {
const { user, isAuthenticated } = useAuthStore()
const hasPermission = (permission: string): boolean => {
if (!isAuthenticated || !user) return false
const rolePermissions = ROLE_PERMISSIONS[user.role] || []
return rolePermissions.includes(permission)
}
const hasRole = (role: string | string[]): boolean => {
if (!isAuthenticated || !user) return false
const allowedRoles = Array.isArray(role) ? role : [role]
return allowedRoles.includes(user.role)
}
const canCreateEmployee = (): boolean => {
return hasPermission('employees:create')
}
const canEditEmployee = (employeeId?: string): boolean => {
if (!isAuthenticated || !user) return false
// Admins can edit anyone
if (user.role === 'admin') return true
// Superusers can edit anyone
if (user.role === 'superuser') return true
// Users can only edit their own profile (if linked)
if (user.role === 'user' && employeeId && user.employeeId === employeeId) {
return true
}
return false
}
const canDeleteEmployee = (): boolean => {
return hasPermission('employees:delete')
}
const canAccessAdminPanel = (): boolean => {
return hasPermission('admin:panel:access')
}
const canManageUsers = (): boolean => {
return hasPermission('users:create') || hasPermission('users:update') || hasPermission('users:delete')
}
return {
hasPermission,
hasRole,
canCreateEmployee,
canEditEmployee,
canDeleteEmployee,
canAccessAdminPanel,
canManageUsers,
user,
isAuthenticated
}
}

10
frontend/src/main.tsx Normale Datei
Datei anzeigen

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

66
frontend/src/services/api.ts Normale Datei
Datei anzeigen

@ -0,0 +1,66 @@
import axios from 'axios'
import { Employee } from '@skillmate/shared'
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Auth interceptor
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export const authApi = {
login: async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password })
return response.data.data
},
logout: async () => {
localStorage.removeItem('token')
}
}
export const employeeApi = {
getAll: async () => {
const response = await api.get('/employees/public')
return response.data.data
},
getById: async (id: string) => {
const response = await api.get(`/employees/${id}`)
return response.data.data
},
create: async (employee: Partial<Employee>) => {
const response = await api.post('/employees', employee)
return response.data
},
update: async (id: string, employee: Partial<Employee>) => {
const response = await api.put(`/employees/${id}`, employee)
return response.data.data
},
delete: async (id: string) => {
await api.delete(`/employees/${id}`)
},
search: async (query: string) => {
const response = await api.get(`/employees/search?q=${query}`)
return response.data.data
}
}
export const skillsApi = {
searchBySkills: async (skills: string[]) => {
const response = await api.post<Employee[]>('/skills/search', { skills })
return response.data
}
}
export { api }
export default api

Datei anzeigen

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

Datei anzeigen

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

203
frontend/src/styles/index.css Normale Datei
Datei anzeigen

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

23
frontend/src/temp/skills.ts Normale Datei
Datei anzeigen

@ -0,0 +1,23 @@
export const SKILL_CATEGORIES = {
languages: 'Sprachen',
it: 'IT-Kenntnisse',
investigation: 'Ermittlung',
analysis: 'Analyse',
operations: 'Einsatz',
certificates: 'Zertifikate',
weapons: 'Waffen',
driving: 'Fahrerlaubnisse',
special: 'Sonderqualifikationen'
};
export const DEFAULT_SKILLS = {
languages: ['Deutsch'],
it: ['Python'],
investigation: ['Verdeckte Ermittlung'],
analysis: ['Finanzermittlungen'],
operations: ['Einsatzplanung'],
certificates: ['Sicherheitsüberprüfung Ü2'],
weapons: ['Waffensachkunde'],
driving: ['Führerschein Klasse B'],
special: ['Drohnenpilot']
};

22
frontend/src/types/electron.d.ts vendored Normale Datei
Datei anzeigen

@ -0,0 +1,22 @@
export interface ElectronAPI {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
getVersion: () => Promise<string>
getPath: (name: string) => Promise<string>
onWindowMaximized: (callback: () => void) => void
onWindowUnmaximized: (callback: () => void) => void
removeAllListeners: (channel: string) => void
}
declare global {
interface Window {
electronAPI?: ElectronAPI
}
namespace NodeJS {
interface Process {
platform: string
}
}
}

Datei anzeigen

@ -0,0 +1,270 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
interface DashboardStats {
totalEmployees: number
totalSkills: number
recentEmployees: number
availableEmployees: number
}
export default function Dashboard() {
const navigate = useNavigate()
const { user } = useAuthStore()
const [stats, setStats] = useState<DashboardStats>({
totalEmployees: 0,
totalSkills: 0,
recentEmployees: 0,
availableEmployees: 0,
})
const [employees, setEmployees] = useState<any[]>([])
const [trendWindow, setTrendWindow] = useState<'today' | 7 | 30>('today')
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
try {
const employees = await employeeApi.getAll()
setEmployees(employees)
const totalSkills = employees.reduce((sum: number, emp: any) => sum + emp.skills.length, 0)
const availableEmployees = employees.filter((emp: any) => emp.availability === 'available').length
setStats({
totalEmployees: employees.length,
totalSkills: totalSkills,
recentEmployees: employees.filter((emp: any) => {
const created = new Date(emp.createdAt)
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
return created > weekAgo
}).length,
availableEmployees: availableEmployees,
})
} catch (error) {
console.error('Failed to fetch stats:', error)
// Fallback zu Demo-Daten
setStats({
totalEmployees: 0,
totalSkills: 0,
recentEmployees: 0,
availableEmployees: 0,
})
setEmployees([])
} finally {
// Loading complete
}
}
const statsCards = [
{
title: 'Mitarbeiter gesamt',
value: stats.totalEmployees,
color: 'text-primary-blue',
bgColor: 'bg-bg-accent',
},
{
title: 'Skills erfasst',
value: stats.totalSkills,
color: 'text-success',
bgColor: 'bg-success-bg',
},
]
// Trends: Top-Kompetenzen im Zeitraum (basierend auf aktualisierten Profilen)
const topSkills = useMemo(() => {
if (!employees.length) return [] as { name: string; count: number }[]
const since = new Date()
if (trendWindow === 'today') {
since.setHours(0, 0, 0, 0)
} else {
since.setDate(since.getDate() - trendWindow)
}
const recent = employees.filter((e: any) => {
const updated = new Date(e.updatedAt)
return !isNaN(updated.getTime()) && updated > since
})
const counts = new Map<string, number>()
for (const emp of recent) {
const skills = (emp.skills || []) as { name?: string; id: string }[]
for (const s of skills) {
const key = (s && (s as any).name) || (s && (s as any).id) || 'Unbenannt'
counts.set(key, (counts.get(key) || 0) + 1)
}
}
const arr = Array.from(counts.entries()).map(([name, count]) => ({ name, count }))
arr.sort((a, b) => b.count - a.count)
return arr.slice(0, 5)
}, [employees, trendWindow])
// Profilqualität (mein Profil): Kontakt & Dienststelle + ob Skills vorhanden
const me = useMemo(() => {
if (!user?.employeeId) return null
return employees.find(e => e.id === user.employeeId) || null
}, [employees, user?.employeeId])
const profileQuality = useMemo(() => {
if (!me) return { percent: 0, missing: ['Profil wird geladen oder nicht verknüpft'] as string[], detailed: [] as { key: string; label: string; ok: boolean }[] }
const checks: { key: string; label: string; ok: boolean }[] = [
{ key: 'firstName', label: 'Vorname', ok: !!me.firstName },
{ key: 'lastName', label: 'Nachname', ok: !!me.lastName },
{ key: 'email', label: 'E-Mail', ok: !!me.email },
{ key: 'department', label: 'Dienststelle', ok: !!me.department },
{ key: 'position', label: 'Position', ok: !!me.position },
{ key: 'phone', label: 'Telefon', ok: !!me.phone && me.phone !== 'Nicht angegeben' },
{ key: 'mobile', label: 'Mobil', ok: !!me.mobile },
{ key: 'office', label: 'Büro', ok: !!me.office },
{ key: 'employeeNumber', label: 'NW-Kennung', ok: !!me.employeeNumber },
{ key: 'skills', label: 'Mindestens eine Kompetenz erfasst', ok: Array.isArray(me.skills) && me.skills.length > 0 },
]
const total = checks.length
const done = checks.filter(c => c.ok).length
const percent = Math.round((done / total) * 100)
const missing = checks.filter(c => !c.ok).map(c => c.label)
const detailed = checks
return { percent, missing, detailed }
}, [me])
const qualityBarColor = (percent: number) => {
if (percent <= 33) return 'bg-red-500 dark:bg-red-700'
if (percent <= 66) return 'bg-green-500 dark:bg-green-700'
return 'bg-purple-500 dark:bg-purple-700'
}
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-2 lg:grid-cols-4 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">
{/* Trends */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-title-card font-poppins font-semibold text-primary">Trends</h2>
<div className="flex gap-2">
<button
className={`px-3 py-1 rounded-input border border-border-default text-body ${trendWindow === 'today' ? 'bg-bg-accent text-primary-blue' : 'bg-white dark:bg-dark-bg-secondary'}`}
onClick={() => setTrendWindow('today')}
>
Heute
</button>
<button
className={`px-3 py-1 rounded-input border border-border-default text-body ${trendWindow === 7 ? 'bg-bg-accent text-primary-blue' : 'bg-white dark:bg-dark-bg-secondary'}`}
onClick={() => setTrendWindow(7)}
>
7 Tage
</button>
<button
className={`px-3 py-1 rounded-input border border-border-default text-body ${trendWindow === 30 ? 'bg-bg-accent text-primary-blue' : 'bg-white dark:bg-dark-bg-secondary'}`}
onClick={() => setTrendWindow(30)}
>
30 Tage
</button>
</div>
</div>
<div className="space-y-2">
{topSkills.length === 0 ? (
<p className="text-tertiary">Keine Daten im ausgewählten Zeitraum.</p>
) : (
topSkills.map((s, idx) => (
<div key={s.name + idx} className="flex items-center justify-between border-b border-border-default dark:border-dark-border py-2">
<span className="text-secondary">{s.name}</span>
<span className="text-tertiary">{s.count} Profile</span>
</div>
))
)}
<p className="text-help text-tertiary mt-2">Basis: Profile mit Aktualisierung im Zeitraum.</p>
</div>
</div>
{/* Profilqualität */}
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Profilqualität</h2>
{!user?.employeeId ? (
<p className="text-tertiary">Kein Mitarbeiterprofil mit Ihrem Nutzer verknüpft.</p>
) : (
<>
<div className="w-full h-3 bg-bg-gray dark:bg-dark-primary rounded-input overflow-hidden">
<div
className={`h-full transition-all ${qualityBarColor(profileQuality.percent)}`}
style={{ width: `${profileQuality.percent}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-secondary font-medium">{profileQuality.percent}% vollständig</span>
<button onClick={() => navigate('/profile')} className="text-body text-primary-blue hover:underline">Mein Profil bearbeiten</button>
</div>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{profileQuality.detailed.map((c, i) => (
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-input border border-border-default dark:border-dark-border bg-white dark:bg-dark-bg-secondary">
<span className="text-body text-secondary">{c.label}</span>
{c.ok ? (
<span className="text-green-600 text-sm"></span>
) : (
<span className="text-red-600 text-sm"> fehlt</span>
)}
</div>
))}
</div>
</>
)}
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Schnellzugriff
</h2>
<div className="space-y-3">
<button
onClick={() => navigate('/employees')}
className="w-full btn-secondary"
>
Mitarbeiter verwalten
</button>
<button
onClick={() => navigate('/search')}
className="w-full btn-secondary"
>
Nach Skills suchen
</button>
<button
onClick={() => navigate('/profile')}
className="w-full btn-secondary"
>
Mein Profil bearbeiten
</button>
</div>
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Letzte Aktivitäten
</h2>
<div className="space-y-2">
<p className="text-sm text-tertiary">
Keine aktuellen Aktivitäten
</p>
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,260 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../services/api'
import { Workspace, Booking, WorkspaceFilter } from '@skillmate/shared'
export default function DeskBooking() {
const navigate = useNavigate()
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [myBookings, setMyBookings] = useState<Booking[]>([])
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedTime, setSelectedTime] = useState('09:00')
const [duration, setDuration] = useState(8) // hours
const [filter, setFilter] = useState<WorkspaceFilter>({})
const [loading, setLoading] = useState(true)
const [showBookingModal, setShowBookingModal] = useState(false)
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(null)
useEffect(() => {
loadData()
}, [selectedDate, filter])
const loadData = async () => {
try {
setLoading(true)
// Calculate start and end times
const startTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString()
const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 60 * 1000).toISOString()
// Load available workspaces
const availableResponse = await api.post('/workspaces/availability', {
start_time: startTime,
end_time: endTime,
type: filter.type || 'desk'
})
setWorkspaces(availableResponse.data)
// Load user's bookings
const bookingsResponse = await api.get('/bookings/my-bookings', {
params: {
from_date: selectedDate,
status: 'confirmed'
}
})
setMyBookings(bookingsResponse.data)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const handleBook = async (workspace: Workspace) => {
try {
const startTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString()
const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 60 * 1000).toISOString()
await api.post('/bookings', {
workspace_id: workspace.id,
start_time: startTime,
end_time: endTime
})
// Reload data
await loadData()
// Show success message
alert('Arbeitsplatz erfolgreich gebucht!')
} catch (error: any) {
alert(error.response?.data?.error || 'Buchung fehlgeschlagen')
}
}
const handleCancelBooking = async (bookingId: string) => {
if (!confirm('Möchten Sie diese Buchung wirklich stornieren?')) return
try {
await api.post(`/bookings/${bookingId}/cancel`)
await loadData()
alert('Buchung erfolgreich storniert')
} catch (error) {
alert('Stornierung fehlgeschlagen')
}
}
const handleCheckIn = async (bookingId: string) => {
try {
await api.post(`/bookings/${bookingId}/check-in`)
await loadData()
alert('Check-in erfolgreich')
} catch (error: any) {
alert(error.response?.data?.error || 'Check-in fehlgeschlagen')
}
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Arbeitsplatz buchen
</h1>
<p className="text-gray-600 dark:text-gray-400">
Buchen Sie Ihren Arbeitsplatz flexibel und einfach
</p>
</div>
{/* Filter Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Datum
</label>
<input
type="date"
value={selectedDate}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Startzeit
</label>
<select
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{Array.from({ length: 13 }, (_, i) => i + 6).map(hour => (
<option key={hour} value={`${hour.toString().padStart(2, '0')}:00`}>
{hour}:00 Uhr
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Dauer (Stunden)
</label>
<select
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{[1, 2, 4, 6, 8, 10].map(hours => (
<option key={hours} value={hours}>{hours} Stunden</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Typ
</label>
<select
value={filter.type || 'desk'}
onChange={(e) => setFilter({ ...filter, type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="desk">Schreibtisch</option>
<option value="meeting_room">Meetingraum</option>
<option value="phone_booth">Telefonbox</option>
<option value="parking">Parkplatz</option>
</select>
</div>
</div>
</div>
{/* My Bookings */}
{myBookings.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Meine Buchungen heute
</h2>
<div className="space-y-3">
{myBookings.map(booking => (
<div
key={booking.id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
{booking.workspace?.name || 'Arbeitsplatz'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{new Date(booking.start_time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} -
{new Date(booking.end_time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="flex gap-2">
{!booking.check_in_time && new Date(booking.start_time) <= new Date(new Date().getTime() + 15 * 60 * 1000) && (
<button
onClick={() => handleCheckIn(booking.id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Check-in
</button>
)}
<button
onClick={() => handleCancelBooking(booking.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Stornieren
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Available Workspaces */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Verfügbare Arbeitsplätze
</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : workspaces.length === 0 ? (
<p className="text-gray-600 dark:text-gray-400 text-center py-8">
Keine Arbeitsplätze für den gewählten Zeitraum verfügbar
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{workspaces.map(workspace => (
<div
key={workspace.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition-shadow"
>
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
{workspace.name}
</h3>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400 mb-4">
<p>Etage: {workspace.floor}</p>
{workspace.building && <p>Gebäude: {workspace.building}</p>}
{workspace.equipment && workspace.equipment.length > 0 && (
<p>Ausstattung: {workspace.equipment.join(', ')}</p>
)}
</div>
<button
onClick={() => handleBook(workspace)}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Jetzt buchen
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,232 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import type { Employee } from '@skillmate/shared'
// Dynamische Hierarchie für Darstellung der Nutzer-Skills
import SkillLevelBar from '../components/SkillLevelBar'
import { employeeApi } from '../services/api'
export default function EmployeeDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [employee, setEmployee] = useState<Employee | null>(null)
const [error, setError] = useState('')
const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const toPublic = (p?: string | null) => (p && p.startsWith('/uploads/')) ? `${PUBLIC_BASE}${p}` : (p || '')
useEffect(() => {
if (id) {
fetchEmployee(id)
}
}, [id])
useEffect(() => {
const load = async () => {
try {
const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
})
const data = await res.json()
if (data?.success) setHierarchy(data.data || [])
} catch {}
}
load()
}, [])
const fetchEmployee = async (employeeId: string) => {
try {
const data = await employeeApi.getById(employeeId)
setEmployee(data)
} catch (error) {
console.error('Failed to fetch employee:', error)
// Fallback to mock data
const mockEmployee: Employee = {
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
employeeNumber: 'EMP001',
position: 'Senior Analyst',
department: 'Cybercrime',
email: 'max.mustermann@behörde.de',
phone: '+49 30 12345-100',
mobile: '+49 170 1234567',
office: 'Raum 3.42',
availability: 'available',
skills: [
{ id: '1', name: 'Python', category: 'it', level: 'expert', verified: true },
{ id: '2', name: 'Netzwerkforensik', category: 'it', level: 'advanced', verified: true },
{ id: '3', name: 'OSINT-Tools', category: 'it', level: 'advanced' },
],
languages: [
{ language: 'Deutsch', proficiency: 'native', isNative: true },
{ language: 'Englisch', proficiency: 'fluent', certified: true, certificateType: 'C1' },
{ language: 'Russisch', proficiency: 'intermediate' },
],
clearance: {
level: 'Ü3',
validUntil: new Date('2025-12-31'),
issuedDate: new Date('2021-01-15'),
},
specializations: ['Digitale Forensik', 'Malware-Analyse', 'Darknet-Ermittlungen'],
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'admin',
}
setEmployee(mockEmployee)
}
}
if (!employee) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-tertiary">Mitarbeiter wird geladen...</p>
</div>
)
}
// Hinweis: Verfügbarkeits-Badge wird im Mitarbeiter-Detail nicht angezeigt
return (
<div>
<button
onClick={() => navigate('/employees')}
className="text-primary-blue hover:text-primary-blue-hover mb-6 flex items-center space-x-2"
>
<span> Zurück zur Übersicht</span>
</button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="card">
<div className="flex flex-col items-center">
<div className="mb-4 w-40 h-40 rounded-full bg-bg-accent dark:bg-dark-primary overflow-hidden flex items-center justify-center">
{employee.photo ? (
<img src={toPublic(employee.photo)} alt="Foto" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-primary-blue dark:text-dark-accent text-3xl font-semibold">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</div>
)}
</div>
<h1 className="text-title-dialog font-poppins font-bold text-primary text-center">
{employee.firstName} {employee.lastName}
</h1>
{employee.employeeNumber && (
<p className="text-body text-tertiary mb-4">{employee.employeeNumber}</p>
)}
</div>
</div>
<div className="card mt-6">
<h3 className="text-body font-poppins font-semibold text-primary mb-4">
Kontaktdaten
</h3>
<div className="space-y-3 text-body">
<div>
<span className="text-tertiary">E-Mail:</span>
<p className="text-secondary">{employee.email}</p>
</div>
<div>
<span className="text-tertiary">Telefon:</span>
<p className="text-secondary">{employee.phone}</p>
</div>
{employee.mobile && (
<div>
<span className="text-tertiary">Mobil:</span>
<p className="text-secondary">{employee.mobile}</p>
</div>
)}
{employee.office && (
<div>
<span className="text-tertiary">Büro:</span>
<p className="text-secondary">{employee.office}</p>
</div>
)}
</div>
</div>
</div>
<div className="lg:col-span-2 space-y-6">
<div className="card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-4">
Allgemeine Informationen
</h3>
<div className="grid grid-cols-2 gap-4 text-body">
<div>
<span className="text-tertiary">Position:</span>
<p className="text-secondary font-medium">{employee.position}</p>
</div>
<div>
<span className="text-tertiary">Dienststelle:</span>
<p className="text-secondary font-medium">{employee.department}</p>
</div>
{employee.clearance && (
<div>
<span className="text-tertiary">Sicherheitsüberprüfung:</span>
<p className="text-secondary font-medium">
{employee.clearance.level} (gültig bis {new Date(employee.clearance.validUntil).toLocaleDateString('de-DE')})
</p>
</div>
)}
</div>
</div>
<div className="card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h3>
<div className="space-y-4">
{hierarchy.map(cat => {
const subs = cat.subcategories.map(sub => {
const selected = sub.skills.filter(sk => employee.skills.some(es => es.id === sk.id))
return { sub, selected }
}).filter(x => x.selected.length > 0)
if (subs.length === 0) return null
return (
<div key={cat.id}>
<h4 className="text-body font-semibold text-secondary mb-2">{cat.name}</h4>
{subs.map(({ sub, selected }) => (
<div key={`${cat.id}.${sub.id}`} className="mb-3">
<div className="px-2 py-1 rounded-input border border-border-default bg-bg-accent dark:bg-dark-primary text-body font-medium text-primary mb-2">
{sub.name}
</div>
<ul className="space-y-3 text-body">
{selected.map((sk) => {
const info = employee.skills.find(es => es.id === sk.id)
const levelVal = info?.level ? Number(info.level) : ''
return (
<li key={sk.id}>
<div className="flex items-center justify-between mb-1">
<span className="text-secondary">{sk.name}</span>
</div>
<SkillLevelBar value={levelVal as any} onChange={() => {}} disabled showHelp={false} />
</li>
)
})}
</ul>
</div>
))}
</div>
)
})}
</div>
</div>
{employee.specializations.length > 0 && (
<div className="card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-4">
Spezialisierungen
</h3>
<div className="flex flex-wrap gap-2">
{employee.specializations.map((spec, index) => (
<span key={index} className="badge badge-info">
{spec}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,684 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api'
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories'
import PhotoPreview from '../components/PhotoPreview'
export default function EmployeeForm() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
employeeNumber: '',
position: '',
department: '',
email: '',
phone: '',
mobile: '',
office: '',
clearance: '',
availability: 'available' as 'available' | 'parttime' | 'unavailable',
partTimeHours: '',
skills: [] as any[],
languages: [] as string[],
specializations: [] as string[]
})
const [employeePhoto, setEmployeePhoto] = useState<string | null>(null)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set())
const [skillSearchTerm, setSkillSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const toggleSubCategory = (subCategoryId: string) => {
setExpandedSubCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(subCategoryId)) {
newSet.delete(subCategoryId)
} else {
newSet.add(subCategoryId)
}
return newSet
})
}
const handleSkillToggle = (categoryId: string, subCategoryId: string, skillId: string, skillName: string) => {
setFormData(prev => {
const skills = [...prev.skills]
const existingIndex = skills.findIndex(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
if (existingIndex > -1) {
skills.splice(existingIndex, 1)
} else {
skills.push({
categoryId,
subCategoryId,
skillId,
name: skillName,
level: ''
})
}
return { ...prev, skills }
})
}
const handleSkillLevelChange = (categoryId: string, subCategoryId: string, skillId: string, level: string) => {
setFormData(prev => {
const skills = [...prev.skills]
const skill = skills.find(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
if (skill) {
skill.level = level
}
return { ...prev, skills }
})
}
const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) => {
return formData.skills.some(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
}
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) => {
const skill = formData.skills.find(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
return skill?.level || ''
}
// Skill-Suche
const handleSkillSearch = (searchTerm: string) => {
setSkillSearchTerm(searchTerm)
if (searchTerm.length < 2) {
setSearchResults([])
return
}
const results: any[] = []
const lowerSearch = searchTerm.toLowerCase()
SKILL_HIERARCHY.forEach(category => {
category.subcategories.forEach(subCategory => {
subCategory.skills.forEach(skill => {
if (skill.name.toLowerCase().includes(lowerSearch)) {
results.push({
categoryId: category.id,
categoryName: category.name,
subCategoryId: subCategory.id,
subCategoryName: subCategory.name,
skillId: skill.id,
skillName: skill.name
})
}
})
})
})
setSearchResults(results)
}
const handleSearchResultClick = (result: any) => {
// Öffne die entsprechenden Kategorien
setExpandedCategories(prev => new Set([...prev, result.categoryId]))
setExpandedSubCategories(prev => new Set([...prev, `${result.categoryId}-${result.subCategoryId}`]))
// Wähle den Skill aus
handleSkillToggle(result.categoryId, result.subCategoryId, result.skillId, result.skillName)
// Lösche die Suche
setSkillSearchTerm('')
setSearchResults([])
}
const validateForm = () => {
const errors: Record<string, string> = {}
if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich'
if (!formData.lastName.trim()) errors.lastName = 'Nachname ist erforderlich'
if (!formData.employeeNumber.trim()) errors.employeeNumber = 'Mitarbeiternummer ist erforderlich'
if (!formData.position.trim()) errors.position = 'Position ist erforderlich'
if (!formData.department.trim()) errors.department = 'Abteilung ist erforderlich'
if (!formData.email.trim()) errors.email = 'E-Mail ist erforderlich'
else if (!/\S+@\S+\.\S+/.test(formData.email)) errors.email = 'Ungültige E-Mail-Adresse'
if (!formData.phone.trim()) errors.phone = 'Telefonnummer ist erforderlich'
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setValidationErrors({})
if (!validateForm()) {
setError('Bitte füllen Sie alle Pflichtfelder aus')
return
}
setLoading(true)
try {
const newEmployee: Partial<Employee> = {
...formData,
skills: formData.skills.map((skill, index) => ({
id: `skill-${index}`,
name: skill.name,
category: skill.categoryId,
level: skill.level || 3
})),
languages: formData.skills
.filter(s => s.subCategoryId === 'languages')
.map(s => ({
language: s.name,
proficiency: s.level || 'B1'
})),
clearance: formData.clearance ? {
level: formData.clearance as 'Ü2' | 'Ü3',
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 5)),
issuedDate: new Date()
} : undefined,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'admin'
}
const result = await employeeApi.create(newEmployee)
const newEmployeeId = result.data.id
// Upload photo if we have one
if (photoFile && newEmployeeId) {
const formData = new FormData()
formData.append('photo', photoFile)
try {
await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
})
} catch (uploadError) {
console.error('Failed to upload photo:', uploadError)
}
}
navigate('/employees')
} catch (err: any) {
if (err.response?.status === 401) {
setError('Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an.')
} else {
setError(err.response?.data?.message || 'Fehler beim Erstellen des Mitarbeiters')
}
} finally {
setLoading(false)
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
Neuer Mitarbeiter
</h1>
<button
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
</div>
{error && (
<div className="bg-error-bg text-error p-4 rounded-card mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Persönliche Informationen */}
<div className="form-card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Persönliche Informationen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2 flex justify-center mb-6">
<div>
<label className="block text-sm font-medium text-secondary mb-2 text-center">
Mitarbeiterfoto
</label>
<PhotoPreview
currentPhoto={employeePhoto || undefined}
onPhotoSelect={(file) => {
setPhotoFile(file)
// Create preview URL
const reader = new FileReader()
reader.onloadend = () => {
setEmployeePhoto(reader.result as string)
}
reader.readAsDataURL(file)
}}
onPhotoRemove={() => {
setPhotoFile(null)
setEmployeePhoto(null)
}}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Vorname *
</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className={`input-field ${validationErrors.firstName ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.firstName && (
<p className="mt-1 text-sm text-red-600">{validationErrors.firstName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Nachname *
</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className={`input-field ${validationErrors.lastName ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.lastName && (
<p className="mt-1 text-sm text-red-600">{validationErrors.lastName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Personalnummer *
</label>
<input
type="text"
name="employeeNumber"
value={formData.employeeNumber}
onChange={handleChange}
className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.employeeNumber && (
<p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
E-Mail *
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`input-field ${validationErrors.email ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.email && (
<p className="mt-1 text-sm text-red-600">{validationErrors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Position *
</label>
<input
type="text"
name="position"
value={formData.position}
onChange={handleChange}
className={`input-field ${validationErrors.position ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.position && (
<p className="mt-1 text-sm text-red-600">{validationErrors.position}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Abteilung *
</label>
<input
type="text"
name="department"
value={formData.department}
onChange={handleChange}
className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.department && (
<p className="mt-1 text-sm text-red-600">{validationErrors.department}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Telefon *
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.phone && (
<p className="mt-1 text-sm text-red-600">{validationErrors.phone}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Mobil
</label>
<input
type="tel"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Büro
</label>
<input
type="text"
name="office"
value={formData.office}
onChange={handleChange}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Sicherheitsfreigabe
</label>
<select
name="clearance"
value={formData.clearance}
onChange={handleChange}
className="input-field"
>
<option value="">Keine</option>
<option value="Ü2">Ü2</option>
<option value="Ü3">Ü3</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Verfügbarkeit
</label>
<select
name="availability"
value={formData.availability}
onChange={handleChange}
className="input-field"
>
<option value="available">Verfügbar</option>
<option value="parttime">Teilzeit</option>
<option value="unavailable">Nicht verfügbar</option>
</select>
</div>
{formData.availability === 'parttime' && (
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Stunden pro Woche
</label>
<input
type="text"
name="partTimeHours"
value={formData.partTimeHours}
onChange={handleChange}
placeholder="z.B. 20 Stunden"
className="input-field"
/>
</div>
)}
</div>
</div>
{/* Skills */}
<div className="form-card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary">
Fähigkeiten und Qualifikationen
</h2>
{/* Skill-Suche */}
<div className="relative w-64">
<input
type="text"
value={skillSearchTerm}
onChange={(e) => handleSkillSearch(e.target.value)}
placeholder="Skills suchen..."
className="input-field w-full pl-10 pr-3"
/>
<svg
className="absolute left-3 top-4 w-4 h-4 text-text-placeholder pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{/* Suchergebnisse */}
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto z-10">
{searchResults.map((result, index) => (
<button
key={index}
type="button"
onClick={() => handleSearchResultClick(result)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-600 last:border-b-0 text-gray-900 dark:text-white"
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 dark:text-white">{result.skillName}</span>
{isSkillSelected(result.categoryId, result.subCategoryId, result.skillId) && (
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{result.categoryName} {result.subCategoryName}
</div>
</button>
))}
</div>
)}
{skillSearchTerm.length >= 2 && searchResults.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">Keine Skills gefunden</p>
</div>
)}
</div>
</div>
<div className="space-y-4">
{SKILL_HIERARCHY.map(category => (
<div key={category.id} className="border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
{/* Oberste Kategorie */}
<button
type="button"
onClick={() => toggleCategory(category.id)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white"
>
<span className="font-semibold text-gray-900 dark:text-white">{category.name}</span>
<svg
className={`w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Unterkategorien */}
{expandedCategories.has(category.id) && (
<div className="border-t border-gray-300 dark:border-gray-600">
{category.subcategories.map(subCategory => (
<div key={subCategory.id} className="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
{/* Mittlere Kategorie */}
<button
type="button"
onClick={() => toggleSubCategory(`${category.id}-${subCategory.id}`)}
className="w-full px-6 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-800 dark:text-gray-200"
>
<span className="text-gray-700 dark:text-gray-300">{subCategory.name}</span>
<svg
className={`w-4 h-4 text-gray-600 dark:text-gray-400 transition-transform ${
expandedSubCategories.has(`${category.id}-${subCategory.id}`) ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Skills */}
{expandedSubCategories.has(`${category.id}-${subCategory.id}`) && (
<div className="px-8 py-3 bg-gray-50 dark:bg-gray-900 space-y-2">
{subCategory.skills.map(skill => (
<div key={skill.id} className="flex items-center space-x-3">
<label className="flex items-center space-x-2 flex-1">
<input
type="checkbox"
checked={isSkillSelected(category.id, subCategory.id, skill.id)}
onChange={() => handleSkillToggle(category.id, subCategory.id, skill.id, skill.name)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:text-blue-400"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{skill.name}</span>
</label>
{/* Niveauauswahl */}
{isSkillSelected(category.id, subCategory.id, skill.id) && (
subCategory.id === 'languages' ? (
<select
value={getSkillLevel(category.id, subCategory.id, skill.id)}
onChange={(e) => handleSkillLevelChange(category.id, subCategory.id, skill.id, e.target.value)}
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Niveau wählen</option>
{LANGUAGE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
) : (
<select
value={getSkillLevel(category.id, subCategory.id, skill.id)}
onChange={(e) => handleSkillLevelChange(category.id, subCategory.id, skill.id, e.target.value)}
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Niveau wählen</option>
<option value="Beginner">Anfänger</option>
<option value="Basic">Grundkenntnisse</option>
<option value="Intermediate">Fortgeschritten</option>
<option value="Advanced">Sehr fortgeschritten</option>
<option value="Expert">Experte</option>
</select>
)
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
{/* Submit */}
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
className="btn-primary"
>
{loading ? 'Wird gespeichert...' : 'Mitarbeiter erstellen'}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,197 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard'
import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import { usePermissions } from '../hooks/usePermissions'
export default function EmployeeList() {
const navigate = useNavigate()
const { user } = useAuthStore()
const { canCreateEmployee } = usePermissions()
const [employees, setEmployees] = useState<Employee[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [filteredEmployees, setFilteredEmployees] = useState<Employee[]>([])
const [filters, setFilters] = useState({
department: '',
skills: '',
availability: ''
})
const [showFilters, setShowFilters] = useState(false)
useEffect(() => {
fetchEmployees()
}, [])
const fetchEmployees = async () => {
try {
const data = await employeeApi.getAll()
setEmployees(data)
setFilteredEmployees(data)
} catch (error) {
console.error('Failed to fetch employees:', error)
// Fallback zu Mock-Daten bei Fehler
const mockEmployees: Employee[] = [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
employeeNumber: 'EMP001',
position: 'Senior Analyst',
department: 'Cybercrime',
email: 'max.mustermann@behörde.de',
phone: '+49 30 12345-100',
availability: 'available',
skills: [],
languages: [],
specializations: ['Digitale Forensik', 'Malware-Analyse'],
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'admin',
},
// Add more mock data as needed
]
setEmployees(mockEmployees)
setFilteredEmployees(mockEmployees)
}
}
useEffect(() => {
let filtered = employees.filter(emp => {
// Text search
const matchesSearch = searchTerm === '' ||
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.position.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.specializations.some(spec => spec.toLowerCase().includes(searchTerm.toLowerCase()))
// Department filter
const matchesDepartment = filters.department === '' || emp.department === filters.department
// Availability filter
const matchesAvailability = filters.availability === '' || emp.availability === filters.availability
// Skills filter (basic - später erweitern)
const matchesSkills = filters.skills === '' ||
emp.specializations.some(spec => spec.toLowerCase().includes(filters.skills.toLowerCase()))
return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills
})
setFilteredEmployees(filtered)
}, [searchTerm, employees, filters])
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Mitarbeiter & Expert:innen
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredEmployees.length} von {employees.length} Mitarbeitern
</p>
{canCreateEmployee() && (
<button
onClick={() => navigate('/employees/new')}
className="btn-primary"
>
Mitarbeiter hinzufügen
</button>
)}
</div>
{/* Erweiterte Suchleiste */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="flex gap-4 mb-4">
<div className="flex-1 relative">
<input
type="text"
placeholder="Suche nach Name, Kompetenzen, Abteilung..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/>
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Filter {showFilters ? 'ausblenden' : 'einblenden'}
</button>
</div>
{/* Erweiterte Filter */}
{showFilters && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dienststelle
</label>
<select
value={filters.department}
onChange={(e) => setFilters(prev => ({ ...prev, department: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle Dienststellen</option>
<option value="Cybercrime">Cybercrime</option>
<option value="Staatsschutz">Staatsschutz</option>
<option value="Kriminalpolizei">Kriminalpolizei</option>
<option value="IT">IT</option>
<option value="Verwaltung">Verwaltung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Verfügbarkeit
</label>
<select
value={filters.availability}
onChange={(e) => setFilters(prev => ({ ...prev, availability: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle</option>
<option value="available">Verfügbar</option>
<option value="busy">Beschäftigt</option>
<option value="away">Abwesend</option>
<option value="vacation">Urlaub</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kompetenzen
</label>
<input
type="text"
placeholder="z.B. Forensik, Analyse..."
value={filters.skills}
onChange={(e) => setFilters(prev => ({ ...prev, skills: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredEmployees.map((employee) => (
<EmployeeCard
key={employee.id}
employee={employee}
onClick={() => navigate(`/employees/${employee.id}`)}
/>
))}
</div>
{filteredEmployees.length === 0 && (
<div className="text-center py-12">
<p className="text-tertiary">Keine Mitarbeiter gefunden</p>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,251 @@
import React, { useState, useEffect } from 'react'
import { api } from '../services/api'
import { Workspace, Booking } from '@skillmate/shared'
interface WorkspaceWithStatus extends Workspace {
isBooked?: boolean
currentUser?: {
name: string
photo?: string
}
}
export default function FloorPlan() {
const [selectedFloor, setSelectedFloor] = useState('1')
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedTime, setSelectedTime] = useState(new Date().toISOString().substring(11, 16))
const [workspaces, setWorkspaces] = useState<WorkspaceWithStatus[]>([])
const [loading, setLoading] = useState(true)
const [hoveredWorkspace, setHoveredWorkspace] = useState<WorkspaceWithStatus | null>(null)
const floors = ['EG', '1', '2', '3', '4']
useEffect(() => {
loadFloorData()
}, [selectedFloor, selectedDate, selectedTime])
const loadFloorData = async () => {
try {
setLoading(true)
// Get all workspaces for the floor
const workspaceResponse = await api.get('/workspaces', {
params: { floor: selectedFloor }
})
// Get bookings for the selected time
const dateTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString()
const bookingResponse = await api.get('/bookings', {
params: {
from_date: dateTime,
to_date: dateTime,
status: 'confirmed'
}
})
// Map bookings to workspaces
const bookingMap = new Map()
bookingResponse.data.forEach((booking: any) => {
if (new Date(booking.start_time) <= new Date(dateTime) &&
new Date(booking.end_time) > new Date(dateTime)) {
bookingMap.set(booking.workspace_id, {
name: `${booking.first_name} ${booking.last_name}`,
photo: booking.photo
})
}
})
// Combine workspace and booking data
const workspacesWithStatus = workspaceResponse.data.map((ws: Workspace) => ({
...ws,
isBooked: bookingMap.has(ws.id),
currentUser: bookingMap.get(ws.id)
}))
setWorkspaces(workspacesWithStatus)
} catch (error) {
console.error('Failed to load floor data:', error)
} finally {
setLoading(false)
}
}
const getWorkspaceColor = (workspace: WorkspaceWithStatus) => {
if (!workspace.is_active) return 'bg-gray-300'
if (workspace.isBooked) return 'bg-red-500'
return 'bg-green-500'
}
const renderWorkspace = (workspace: WorkspaceWithStatus) => {
const size = workspace.type === 'meeting_room' ? 'w-24 h-16' : 'w-16 h-12'
return (
<div
key={workspace.id}
className={`absolute ${size} ${getWorkspaceColor(workspace)} rounded cursor-pointer transition-all hover:scale-105 flex items-center justify-center text-white text-xs font-medium`}
style={{
left: `${(workspace.position_x || 0) * 4}px`,
top: `${(workspace.position_y || 0) * 4}px`
}}
onMouseEnter={() => setHoveredWorkspace(workspace)}
onMouseLeave={() => setHoveredWorkspace(null)}
>
{workspace.name}
</div>
)
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Anwesenheitsübersicht
</h1>
<p className="text-gray-600 dark:text-gray-400">
Sehen Sie auf einen Blick, wer wo im Büro ist
</p>
</div>
{/* Controls */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Etage
</label>
<select
value={selectedFloor}
onChange={(e) => setSelectedFloor(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{floors.map(floor => (
<option key={floor} value={floor}>Etage {floor}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Datum
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Uhrzeit
</label>
<input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="flex items-center gap-6 mt-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span className="text-sm text-gray-600 dark:text-gray-400">Frei</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded"></div>
<span className="text-sm text-gray-600 dark:text-gray-400">Belegt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-300 rounded"></div>
<span className="text-sm text-gray-600 dark:text-gray-400">Inaktiv</span>
</div>
</div>
</div>
{/* Floor Plan */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
{loading ? (
<div className="text-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : (
<>
<div className="relative bg-gray-100 dark:bg-gray-900 rounded-lg p-8" style={{ minHeight: '600px' }}>
{/* Grid background */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'repeating-linear-gradient(0deg, #888 0, #888 1px, transparent 1px, transparent 40px), repeating-linear-gradient(90deg, #888 0, #888 1px, transparent 1px, transparent 40px)',
backgroundSize: '40px 40px'
}}
/>
{/* Workspaces */}
{workspaces.map(workspace => renderWorkspace(workspace))}
{/* Hover tooltip */}
{hoveredWorkspace && (
<div className="absolute z-10 bg-gray-900 text-white p-3 rounded-lg shadow-lg"
style={{
left: `${(hoveredWorkspace.position_x || 0) * 4 + 70}px`,
top: `${(hoveredWorkspace.position_y || 0) * 4}px`
}}>
<p className="font-medium">{hoveredWorkspace.name}</p>
<p className="text-sm text-gray-300">
{hoveredWorkspace.type === 'desk' ? 'Arbeitsplatz' :
hoveredWorkspace.type === 'meeting_room' ? 'Meetingraum' :
hoveredWorkspace.type === 'phone_booth' ? 'Telefonbox' : 'Sonstiges'}
</p>
{hoveredWorkspace.isBooked && hoveredWorkspace.currentUser && (
<div className="mt-2 pt-2 border-t border-gray-700">
<p className="text-sm">Belegt von:</p>
<p className="font-medium">{hoveredWorkspace.currentUser.name}</p>
</div>
)}
{hoveredWorkspace.equipment && hoveredWorkspace.equipment.length > 0 && (
<p className="text-sm text-gray-300 mt-1">
{hoveredWorkspace.equipment.join(', ')}
</p>
)}
</div>
)}
</div>
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Gesamt</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{workspaces.length}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Frei</p>
<p className="text-2xl font-bold text-green-600">
{workspaces.filter(ws => ws.is_active && !ws.isBooked).length}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Belegt</p>
<p className="text-2xl font-bold text-red-600">
{workspaces.filter(ws => ws.isBooked).length}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Auslastung</p>
<p className="text-2xl font-bold text-blue-600">
{workspaces.length > 0
? Math.round((workspaces.filter(ws => ws.isBooked).length / workspaces.filter(ws => ws.is_active).length) * 100)
: 0}%
</p>
</div>
</div>
</>
)}
</div>
</div>
)
}

91
frontend/src/views/Login.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,91 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { authApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
export default function Login() {
const navigate = useNavigate()
const { login } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await authApi.login(email, password)
localStorage.setItem('token', response.token.accessToken)
login(response.user, response.token.accessToken)
navigate('/')
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Anmeldung fehlgeschlagen')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-primary flex items-center justify-center">
<div className="card max-w-md w-full">
<h1 className="text-title-lg font-poppins font-bold text-primary text-center mb-8">
SkillMate Login
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary mb-2">
E-Mail-Adresse
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-field"
placeholder="E-Mail-Adresse eingeben"
required
autoFocus
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary mb-2">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field"
placeholder="Passwort eingeben"
required
/>
</div>
{error && (
<div className="bg-error-bg text-error p-3 rounded-input text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
{loading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center text-sm text-tertiary">
<p>Für erste Anmeldung wenden Sie sich an Ihren Administrator</p>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,255 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
import { employeeApi } from '../services/api'
import PhotoUpload from '../components/PhotoUpload'
import SkillLevelBar from '../components/SkillLevelBar'
interface SkillSelection { categoryId: string; subCategoryId: string; skillId: string; name: string; level: string }
export default function MyProfile() {
const { user } = useAuthStore()
const employeeId = user?.employeeId
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [form, setForm] = useState<any | null>(null)
const [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
const [skills, setSkills] = useState<SkillSelection[]>([])
useEffect(() => {
if (!employeeId) {
setLoading(false)
return
}
load()
}, [employeeId])
const load = async () => {
if (!employeeId) return
setLoading(true)
setError('')
try {
// Load employee first
const data = await employeeApi.getById(employeeId)
setForm({ ...data, email: user?.email || data.email || '' })
const mapped: SkillSelection[] = (data.skills || []).map((s: any) => {
const catStr = s.category || ''
const [catId, subId] = String(catStr).split('.')
return {
categoryId: catId || '',
subCategoryId: subId || '',
skillId: s.id,
name: s.name,
level: (s.level || '').toString()
}
})
setSkills(mapped)
// Load hierarchy non-critically
try {
const hierRes = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}).then(r => r.json())
if (hierRes?.success) setCatalog(hierRes.data || [])
} catch {
// ignore hierarchy errors; keep profile usable
}
} catch (e: any) {
setError('Profil konnte nicht geladen werden')
} finally {
setLoading(false)
}
}
const handleSkillToggle = (categoryId: string, subCategoryId: string, skillId: string, skillName: string) => {
setSkills(prev => {
const list = [...prev]
const idx = list.findIndex(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)
if (idx >= 0) {
list.splice(idx, 1)
} else {
list.push({ categoryId, subCategoryId, skillId, name: skillName, level: '' })
}
return list
})
}
const handleSkillLevelChange = (categoryId: string, subCategoryId: string, skillId: string, level: string) => {
setSkills(prev => prev.map(s => (s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) ? { ...s, level } : s))
}
const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) =>
skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) =>
(skills.find(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)?.level) || ''
const onSave = async () => {
if (!employeeId || !form) return
setSaving(true)
setError('')
setSuccess('')
try {
const payload = {
firstName: form.firstName,
lastName: form.lastName,
position: form.position || 'Mitarbeiter',
department: form.department || '',
employeeNumber: form.employeeNumber || undefined,
email: user?.email || form.email,
phone: form.phone || '',
mobile: form.mobile || null,
office: form.office || null,
availability: form.availability || 'available',
skills: skills.map((s, i) => ({ id: s.skillId, name: s.name, category: s.categoryId, level: Number(s.level) || 3 })),
languages: form.languages || [],
specializations: form.specializations || []
}
await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + `/employees/${employeeId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(payload)
}).then(async r => {
const d = await r.json()
if (!r.ok) throw new Error(d?.error?.message || 'Speichern fehlgeschlagen')
})
setSuccess('Profil gespeichert')
await load()
} catch (e: any) {
setError(e.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
if (!employeeId) {
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-4">Mein Profil</h1>
<p className="text-tertiary">Kein Mitarbeiterprofil mit Ihrem Nutzer verknüpft.</p>
</div>
)
}
if (loading) {
return <div className="text-secondary">Lade Profil...</div>
}
if (!form) {
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-4">Mein Profil</h1>
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error || 'Profil konnte nicht geladen werden'}
</div>
</div>
)
}
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">Mein Profil</h1>
{error && (<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>)}
{success && (<div className="bg-success-bg text-success px-4 py-3 rounded-input text-sm mb-6">{success}</div>)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Foto</h2>
<PhotoUpload employeeId={employeeId} currentPhoto={form.photo} onPhotoUpdate={(url) => setForm((prev: any) => ({ ...prev, photo: url }))} />
</div>
<div className="card lg:col-span-2">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kontakt & Dienststelle</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">E-Mail</label>
<input className="input-field w-full disabled:opacity-70" value={user?.email || form.email || ''} disabled readOnly placeholder="wird aus Login übernommen" title="Wird automatisch aus dem Login gesetzt" />
<p className="text-small text-tertiary mt-1">Wird automatisch aus dem Login übernommen. Änderung ggf. im Admin Panel.</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Position</label>
<input className="input-field w-full" value={form.position || ''} onChange={(e) => setForm((p: any) => ({ ...p, position: e.target.value }))} placeholder="z. B. Sachbearbeiter; Führungskraft g. D.; Führungskraft h. D." />
<p className="text-small text-tertiary mt-1">Beispiele: Sachbearbeiter, Führungskraft g. D., Führungskraft h. D.</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">NW-Kennung</label>
<input className="input-field w-full" value={form.employeeNumber || ''} onChange={(e) => setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" />
<p className="text-small text-tertiary mt-1">Ihre behördliche Kennung, z. B. NW068111.</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Dienststelle</label>
<input className="input-field w-full" value={form.department || ''} onChange={(e) => setForm((p: any) => ({ ...p, department: e.target.value }))} placeholder="z. B. Abteilung 4, Dezernat 42, Sachgebiet 42.1" />
<p className="text-small text-tertiary mt-1">Hierarchische Angabe (Abteilung, Dezernat, Sachgebiet).</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Telefon</label>
<input className="input-field w-full" value={form.phone || ''} onChange={(e) => setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Mobil</label>
<input className="input-field w-full" value={form.mobile || ''} onChange={(e) => setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Büro</label>
<input className="input-field w-full" value={form.office || ''} onChange={(e) => setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" />
<p className="text-small text-tertiary mt-1">Angabe zum Standort, z. B. Gebäude, Etage und Raum.</p>
</div>
</div>
</div>
</div>
<div className="card mt-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h2>
<div className="space-y-4">
{catalog.map(category => (
<div key={category.id}>
<h3 className="text-body font-semibold text-secondary mb-2">{category.name}</h3>
{category.subcategories.map(sub => (
<div key={`${category.id}-${sub.id}`} className="mb-3">
<p className="text-small text-tertiary mb-2">{sub.name}</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{sub.skills.map(skill => (
<div key={`${category.id}-${sub.id}-${skill.id}`} className={`p-2 border rounded-input ${isSkillSelected(category.id, sub.id, skill.id) ? 'border-primary-blue bg-bg-accent' : 'border-border-default'}`}>
<label className="flex items-center justify-between">
<span className="text-body text-secondary">
<input
type="checkbox"
className="mr-2"
checked={isSkillSelected(category.id, sub.id, skill.id)}
onChange={() => handleSkillToggle(category.id, sub.id, skill.id, skill.name)}
/>
{skill.name}
</span>
{isSkillSelected(category.id, sub.id, skill.id) && (
<div className="ml-3 flex-1">
<SkillLevelBar
value={Number(getSkillLevel(category.id, sub.id, skill.id)) || ''}
onChange={(val) => handleSkillLevelChange(category.id, sub.id, skill.id, String(val))}
/>
</div>
)}
</label>
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
</div>
<div className="flex justify-end mt-6">
<button onClick={onSave} disabled={saving} className="btn-primary">
{saving ? 'Speichere...' : 'Änderungen speichern'}
</button>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,685 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Save, X, Plus, Trash2, AlertCircle, Check } from 'lucide-react'
import { api } from '../services/api'
import { useAuthStore } from '../stores/authStore'
interface ProfileForm {
name: string
department: string
location: string
role: string
contacts: {
email: string
phone: string
teams: string
}
domains: string[]
tools: string[]
methods: string[]
industryKnowledge: string[]
regulatory: string[]
languages: { code: string; level: 'basic' | 'fluent' | 'native' | 'business' }[]
projects: { title: string; role?: string; summary?: string; links?: string[] }[]
networks: string[]
digitalSkills: string[]
socialSkills: string[]
jobCategory?: string
jobTitle?: string
jobDesc?: string
consentPublicProfile: boolean
consentSearchable: boolean
}
const JOB_CATEGORIES = [
'Technik',
'IT & Digitalisierung',
'Verwaltung',
'F&E',
'Kommunikation & HR',
'Produktion',
'Sonstiges'
]
const LANGUAGE_LEVELS = [
{ value: 'basic', label: 'Grundkenntnisse' },
{ value: 'business', label: 'Verhandlungssicher' },
{ value: 'fluent', label: 'Flie\u00dfend' },
{ value: 'native', label: 'Muttersprache' }
]
export default function ProfileEdit() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuthStore()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [suggestions, setSuggestions] = useState<Record<string, string[]>>({})
const [activeInput, setActiveInput] = useState<string | null>(null)
const [formData, setFormData] = useState<ProfileForm>({
name: '',
department: '',
location: '',
role: '',
contacts: { email: '', phone: '', teams: '' },
domains: [],
tools: [],
methods: [],
industryKnowledge: [],
regulatory: [],
languages: [],
projects: [],
networks: [],
digitalSkills: [],
socialSkills: [],
jobCategory: undefined,
jobTitle: '',
jobDesc: '',
consentPublicProfile: false,
consentSearchable: false
})
useEffect(() => {
if (id && id !== 'new') {
loadProfile()
}
}, [id])
const loadProfile = async () => {
setLoading(true)
try {
const response = await api.get(`/profiles/${id}`)
if (response.data.success) {
setFormData(response.data.data)
}
} catch (error) {
console.error('Error loading profile:', error)
setErrors(['Profil konnte nicht geladen werden'])
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors([])
// Validierung
if (!formData.name) {
setErrors(['Name ist erforderlich'])
return
}
if (!formData.consentPublicProfile && !formData.consentSearchable) {
const confirmed = window.confirm(
'Ihr Profil wird f\u00fcr andere nicht sichtbar sein. M\u00f6chten Sie fortfahren?'
)
if (!confirmed) return
}
setSaving(true)
try {
if (id === 'new') {
const response = await api.post('/profiles', formData)
if (response.data.success) {
setShowSuccess(true)
setTimeout(() => {
navigate(`/profile/${response.data.data.id}`)
}, 1500)
}
} else {
const response = await api.put(`/profiles/${id}`, formData)
if (response.data.success) {
setShowSuccess(true)
setTimeout(() => {
navigate(`/profile/${id}`)
}, 1500)
}
}
} catch (error: any) {
setErrors([error.response?.data?.error?.message || 'Fehler beim Speichern'])
} finally {
setSaving(false)
}
}
const loadSuggestions = async (category: string, query: string) => {
try {
const response = await api.post('/profiles/tags/suggest', {
category,
query
})
if (response.data.success) {
setSuggestions(prev => ({
...prev,
[category]: response.data.data.map((s: any) => s.value)
}))
}
} catch (error) {
console.error('Error loading suggestions:', error)
}
}
const addToArray = (field: keyof ProfileForm, value: string) => {
const array = formData[field] as string[]
if (!array.includes(value)) {
setFormData(prev => ({
...prev,
[field]: [...array, value]
}))
}
}
const removeFromArray = (field: keyof ProfileForm, index: number) => {
const array = formData[field] as string[]
setFormData(prev => ({
...prev,
[field]: array.filter((_, i) => i !== index)
}))
}
const addLanguage = () => {
setFormData(prev => ({
...prev,
languages: [...prev.languages, { code: '', level: 'basic' }]
}))
}
const updateLanguage = (index: number, field: 'code' | 'level', value: string) => {
setFormData(prev => ({
...prev,
languages: prev.languages.map((lang, i) =>
i === index ? { ...lang, [field]: value } : lang
)
}))
}
const removeLanguage = (index: number) => {
setFormData(prev => ({
...prev,
languages: prev.languages.filter((_, i) => i !== index)
}))
}
const addProject = () => {
setFormData(prev => ({
...prev,
projects: [...prev.projects, { title: '', role: '', summary: '', links: [] }]
}))
}
const updateProject = (index: number, field: string, value: any) => {
setFormData(prev => ({
...prev,
projects: prev.projects.map((proj, i) =>
i === index ? { ...proj, [field]: value } : proj
)
}))
}
const removeProject = (index: number) => {
setFormData(prev => ({
...prev,
projects: prev.projects.filter((_, i) => i !== index)
}))
}
if (loading) {
return (
<div className="flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<form onSubmit={handleSubmit} className="space-y-8">
{/* Header */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{id === 'new' ? 'Neues Profil erstellen' : 'Profil bearbeiten'}
</h1>
{errors.length > 0 && (
<div className="mb-4 p-4 bg-red-100 dark:bg-red-900 rounded-lg flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-300 mt-0.5" />
<div>
{errors.map((error, i) => (
<p key={i} className="text-red-600 dark:text-red-300">{error}</p>
))}
</div>
</div>
)}
{showSuccess && (
<div className="mb-4 p-4 bg-green-100 dark:bg-green-900 rounded-lg flex items-center gap-2">
<Check className="w-5 h-5 text-green-600 dark:text-green-300" />
<p className="text-green-600 dark:text-green-300">Profil erfolgreich gespeichert!</p>
</div>
)}
</div>
{/* Basisdaten */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Basisdaten
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rolle/Position
</label>
<input
type="text"
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilung
</label>
<input
type="text"
value={formData.department}
onChange={(e) => setFormData(prev => ({ ...prev, department: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Standort
</label>
<input
type="text"
value={formData.location}
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Kontaktdaten */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Kontaktdaten
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
E-Mail
</label>
<input
type="email"
value={formData.contacts.email}
onChange={(e) => setFormData(prev => ({
...prev,
contacts: { ...prev.contacts, email: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon
</label>
<input
type="tel"
value={formData.contacts.phone}
onChange={(e) => setFormData(prev => ({
...prev,
contacts: { ...prev.contacts, phone: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Teams-Link
</label>
<input
type="text"
value={formData.contacts.teams}
onChange={(e) => setFormData(prev => ({
...prev,
contacts: { ...prev.contacts, teams: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Berufsbilder */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Berufsbild
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kategorie
</label>
<select
value={formData.jobCategory || ''}
onChange={(e) => setFormData(prev => ({ ...prev, jobCategory: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">W\u00e4hlen...</option>
{JOB_CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Berufsbezeichnung
</label>
<input
type="text"
value={formData.jobTitle}
onChange={(e) => setFormData(prev => ({ ...prev, jobTitle: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung
</label>
<textarea
value={formData.jobDesc}
onChange={(e) => setFormData(prev => ({ ...prev, jobDesc: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Kompetenzen */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Kompetenzen
</h2>
<div className="space-y-4">
{/* Tools */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tools & Software
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="Tool hinzuf\u00fcgen..."
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const input = e.target as HTMLInputElement
if (input.value) {
addToArray('tools', input.value)
input.value = ''
}
}
}}
onFocus={() => {
setActiveInput('tools')
loadSuggestions('tools', '')
}}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex flex-wrap gap-2">
{formData.tools.map((tool, i) => (
<span key={i} className="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full flex items-center gap-2">
{tool}
<button
type="button"
onClick={() => removeFromArray('tools', i)}
className="hover:text-red-600"
>
<X className="w-4 h-4" />
</button>
</span>
))}
</div>
</div>
{/* Methoden */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Methoden
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="Methode hinzuf\u00fcgen..."
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const input = e.target as HTMLInputElement
if (input.value) {
addToArray('methods', input.value)
input.value = ''
}
}
}}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex flex-wrap gap-2">
{formData.methods.map((method, i) => (
<span key={i} className="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full flex items-center gap-2">
{method}
<button
type="button"
onClick={() => removeFromArray('methods', i)}
className="hover:text-red-600"
>
<X className="w-4 h-4" />
</button>
</span>
))}
</div>
</div>
</div>
</div>
{/* Sprachen */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Sprachkenntnisse
</h2>
<div className="space-y-2">
{formData.languages.map((lang, i) => (
<div key={i} className="flex gap-2">
<input
type="text"
value={lang.code}
onChange={(e) => updateLanguage(i, 'code', e.target.value)}
placeholder="Sprache (z.B. de, en)"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<select
value={lang.level}
onChange={(e) => updateLanguage(i, 'level', e.target.value as any)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{LANGUAGE_LEVELS.map(level => (
<option key={level.value} value={level.value}>{level.label}</option>
))}
</select>
<button
type="button"
onClick={() => removeLanguage(i)}
className="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
<button
type="button"
onClick={addLanguage}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Sprache hinzuf\u00fcgen
</button>
</div>
</div>
{/* Projekte */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Projekterfahrung
</h2>
<div className="space-y-4">
{formData.projects.map((project, i) => (
<div key={i} className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="space-y-3">
<input
type="text"
value={project.title}
onChange={(e) => updateProject(i, 'title', e.target.value)}
placeholder="Projekttitel"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<input
type="text"
value={project.role || ''}
onChange={(e) => updateProject(i, 'role', e.target.value)}
placeholder="Ihre Rolle im Projekt"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<textarea
value={project.summary || ''}
onChange={(e) => updateProject(i, 'summary', e.target.value)}
placeholder="Kurzbeschreibung"
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
type="button"
onClick={() => removeProject(i)}
className="text-red-600 hover:text-red-700"
>
Projekt entfernen
</button>
</div>
</div>
))}
<button
type="button"
onClick={addProject}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Projekt hinzuf\u00fcgen
</button>
</div>
</div>
{/* Datenschutz */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Datenschutz & Sichtbarkeit
</h2>
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="consentPublic"
checked={formData.consentPublicProfile}
onChange={(e) => setFormData(prev => ({ ...prev, consentPublicProfile: e.target.checked }))}
className="w-5 h-5 text-blue-600"
/>
<label htmlFor="consentPublic" className="text-gray-700 dark:text-gray-300">
Mein Profil darf \u00f6ffentlich angezeigt werden
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="consentSearch"
checked={formData.consentSearchable}
onChange={(e) => setFormData(prev => ({ ...prev, consentSearchable: e.target.checked }))}
className="w-5 h-5 text-blue-600"
/>
<label htmlFor="consentSearch" className="text-gray-700 dark:text-gray-300">
Mein Profil darf in der Suche gefunden werden
</label>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Hinweis:</strong> Ihre Daten werden ausschlie\u00dflich intern verwendet und nicht an Dritte weitergegeben.
Sie k\u00f6nnen diese Einstellungen jederzeit \u00e4ndern.
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="px-6 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Speichern...
</>
) : (
<>
<Save className="w-5 h-5" />
Speichern
</>
)}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,426 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Search, Filter, Download, User, MapPin, Briefcase, Globe, Tool, BookOpen, Users } from 'lucide-react'
import { api } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import { useNavigate } from 'react-router-dom'
interface Profile {
id: string
name: string
department?: string
location?: string
role?: string
jobCategory?: string
jobTitle?: string
tools?: string[]
methods?: string[]
languages?: { code: string; level: string }[]
updatedAt: string
consentPublicProfile: boolean
}
interface Facets {
departments: string[]
locations: string[]
jobCategories: string[]
tools: string[]
methods: string[]
languages: { code: string; level: string }[]
}
export default function ProfileSearch() {
const navigate = useNavigate()
const { user } = useAuthStore()
const [searchQuery, setSearchQuery] = useState('')
const [profiles, setProfiles] = useState<Profile[]>([])
const [facets, setFacets] = useState<Facets | null>(null)
const [loading, setLoading] = useState(false)
const [filters, setFilters] = useState({
department: '',
location: '',
jobCategory: '',
tools: [] as string[],
methods: [] as string[],
language: ''
})
const [showFilters, setShowFilters] = useState(false)
const [page, setPage] = useState(1)
const [totalResults, setTotalResults] = useState(0)
// Lade Facetten beim Start
useEffect(() => {
loadFacets()
}, [])
// Suche bei \u00c4nderungen
useEffect(() => {
const delayDebounce = setTimeout(() => {
searchProfiles()
}, 300)
return () => clearTimeout(delayDebounce)
}, [searchQuery, filters, page])
const loadFacets = async () => {
try {
const response = await api.get('/profiles/facets')
if (response.data.success) {
setFacets(response.data.data)
}
} catch (error) {
console.error('Error loading facets:', error)
}
}
const searchProfiles = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (searchQuery) params.append('query', searchQuery)
if (filters.department) params.append('dept', filters.department)
if (filters.location) params.append('loc', filters.location)
if (filters.jobCategory) params.append('jobCat', filters.jobCategory)
if (filters.tools.length > 0) params.append('tools', filters.tools.join(','))
if (filters.methods.length > 0) params.append('methods', filters.methods.join(','))
if (filters.language) params.append('lang', filters.language)
params.append('page', page.toString())
params.append('pageSize', '20')
const response = await api.get(`/profiles?${params.toString()}`)
if (response.data.success) {
setProfiles(response.data.data)
setTotalResults(response.data.meta?.total || 0)
}
} catch (error) {
console.error('Error searching profiles:', error)
} finally {
setLoading(false)
}
}
const handleExport = async (format: 'json' | 'csv') => {
try {
const response = await api.post('/profiles/export', {
filter: filters,
format
})
if (format === 'csv') {
const blob = new Blob([response.data], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'profiles.csv'
a.click()
} else {
const blob = new Blob([JSON.stringify(response.data.data, null, 2)], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'profiles.json'
a.click()
}
} catch (error) {
console.error('Error exporting profiles:', error)
}
}
const isProfileStale = (updatedAt: string) => {
const oneYearAgo = new Date()
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
return new Date(updatedAt) < oneYearAgo
}
const toggleFilter = (type: 'tools' | 'methods', value: string) => {
setFilters(prev => ({
...prev,
[type]: prev[type].includes(value)
? prev[type].filter(v => v !== value)
: [...prev[type], value]
}))
}
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Expert:innenverzeichnis
</h1>
<p className="text-gray-600 dark:text-gray-400">
Finden Sie schnell die richtigen Ansprechpartner:innen f\u00fcr Ihre Fragen
</p>
</div>
{/* Suchleiste */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Suche nach Namen, Kompetenzen, Projekten..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2"
>
<Filter className="w-5 h-5" />
Filter
</button>
{(user?.role === 'admin' || user?.role === 'poweruser') && (
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center gap-2"
>
<Download className="w-5 h-5" />
CSV
</button>
<button
onClick={() => handleExport('json')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<Download className="w-5 h-5" />
JSON
</button>
</div>
)}
</div>
{/* Filter-Panel */}
{showFilters && facets && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Abteilung */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abteilung
</label>
<select
value={filters.department}
onChange={(e) => setFilters(prev => ({ ...prev, department: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle</option>
{facets.departments.map(dept => (
<option key={dept} value={dept}>{dept}</option>
))}
</select>
</div>
{/* Standort */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Standort
</label>
<select
value={filters.location}
onChange={(e) => setFilters(prev => ({ ...prev, location: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle</option>
{facets.locations.map(loc => (
<option key={loc} value={loc}>{loc}</option>
))}
</select>
</div>
{/* Berufskategorie */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Berufskategorie
</label>
<select
value={filters.jobCategory}
onChange={(e) => setFilters(prev => ({ ...prev, jobCategory: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle</option>
{facets.jobCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
{/* Tools */}
<div className="md:col-span-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tools & Software
</label>
<div className="flex flex-wrap gap-2">
{facets.tools.slice(0, 20).map(tool => (
<button
key={tool}
onClick={() => toggleFilter('tools', tool)}
className={`px-3 py-1 rounded-full text-sm ${
filters.tools.includes(tool)
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{tool}
</button>
))}
</div>
</div>
{/* Methoden */}
<div className="md:col-span-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Methoden
</label>
<div className="flex flex-wrap gap-2">
{facets.methods.slice(0, 20).map(method => (
<button
key={method}
onClick={() => toggleFilter('methods', method)}
className={`px-3 py-1 rounded-full text-sm ${
filters.methods.includes(method)
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{method}
</button>
))}
</div>
</div>
</div>
</div>
)}
</div>
{/* Ergebnisse */}
<div className="space-y-4">
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : profiles.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">
Keine Ergebnisse gefunden. Versuchen Sie es mit anderen Suchbegriffen.
</p>
</div>
) : (
profiles.map(profile => (
<div
key={profile.id}
onClick={() => navigate(`/profile/${profile.id}`)}
className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<User className="w-10 h-10 text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-full p-2" />
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{profile.name}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
{profile.role && (
<span className="flex items-center gap-1">
<Briefcase className="w-4 h-4" />
{profile.role}
</span>
)}
{profile.department && (
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{profile.department}
</span>
)}
{profile.location && (
<span className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
{profile.location}
</span>
)}
</div>
</div>
</div>
{profile.jobTitle && (
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
{profile.jobTitle}
{profile.jobCategory && (
<span className="ml-2 px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded">
{profile.jobCategory}
</span>
)}
</p>
)}
<div className="flex flex-wrap gap-2 mb-3">
{profile.tools?.slice(0, 5).map(tool => (
<span key={tool} className="flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded">
<Tool className="w-3 h-3" />
{tool}
</span>
))}
{profile.methods?.slice(0, 3).map(method => (
<span key={method} className="flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded">
<BookOpen className="w-3 h-3" />
{method}
</span>
))}
{profile.languages?.slice(0, 3).map(lang => (
<span key={lang.code} className="flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded">
<Globe className="w-3 h-3" />
{lang.code}: {lang.level}
</span>
))}
</div>
</div>
<div className="flex flex-col items-end gap-2">
{isProfileStale(profile.updatedAt) ? (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 text-xs rounded">
Veraltet
</span>
) : (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs rounded">
Aktuell
</span>
)}
{!profile.consentPublicProfile && (
<span className="px-2 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-xs rounded">
Privat
</span>
)}
</div>
</div>
</div>
))
)}
</div>
{/* Pagination */}
{totalResults > 20 && (
<div className="flex justify-center gap-2 mt-8">
<button
onClick={() => setPage(prev => Math.max(1, prev - 1))}
disabled={page === 1}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Zur\u00fcck
</button>
<span className="px-4 py-2 text-gray-700 dark:text-gray-300">
Seite {page} von {Math.ceil(totalResults / 20)}
</span>
<button
onClick={() => setPage(prev => prev + 1)}
disabled={page >= Math.ceil(totalResults / 20)}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Weiter
</button>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,137 @@
import { useState } from 'react'
import { useThemeStore } from '../stores/themeStore'
export default function Settings() {
const { isDarkMode, toggleTheme } = useThemeStore()
// Barrierefreiheit-Einstellungen (Platzhalter mit sinnvollen Optionen)
const [a11y, setA11y] = useState({
highContrast: false,
largeText: false,
reduceMotion: false,
underlineLinks: true,
alwaysShowFocusOutline: true,
dyslexiaFriendlyFont: false
})
const handleSave = () => {
// TODO: Save settings to backend/electron store
console.log('Settings saved:', { syncConfig, isDarkMode })
}
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
Einstellungen
</h1>
<div className="max-w-4xl space-y-6">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Darstellung
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-body font-medium text-secondary">Dark Mode</h3>
<p className="text-small text-tertiary">
Aktiviert das dunkle Farbschema der Anwendung
</p>
</div>
<button
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isDarkMode ? 'bg-primary-blue' : 'bg-border-input'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDarkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Barrierefreiheit
</h2>
<div className="space-y-4">
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.highContrast} onChange={(e) => setA11y(prev => ({ ...prev, highContrast: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Hoher Kontrast</span>
<p className="text-small text-tertiary">Erhöht den Kontrast für bessere Lesbarkeit (BITV-konform)</p>
</div>
</label>
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.largeText} onChange={(e) => setA11y(prev => ({ ...prev, largeText: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Große Schrift</span>
<p className="text-small text-tertiary">Vergrößert die Schriftgröße für bessere Lesbarkeit</p>
</div>
</label>
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.reduceMotion} onChange={(e) => setA11y(prev => ({ ...prev, reduceMotion: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Reduzierte Bewegungen</span>
<p className="text-small text-tertiary">Deaktiviert Animationen für weniger Ablenkung</p>
</div>
</label>
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.underlineLinks} onChange={(e) => setA11y(prev => ({ ...prev, underlineLinks: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Links unterstreichen</span>
<p className="text-small text-tertiary">Links werden grundsätzlich unterstrichen dargestellt</p>
</div>
</label>
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.alwaysShowFocusOutline} onChange={(e) => setA11y(prev => ({ ...prev, alwaysShowFocusOutline: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Fokusrahmen immer anzeigen</span>
<p className="text-small text-tertiary">Hebt fokussierte Elemente dauerhaft für Tastaturbedienung hervor</p>
</div>
</label>
<label className="flex items-center space-x-3">
<input type="checkbox" className="w-5 h-5 rounded border-border-input text-primary-blue" checked={a11y.dyslexiaFriendlyFont} onChange={(e) => setA11y(prev => ({ ...prev, dyslexiaFriendlyFont: e.target.checked }))} />
<div>
<span className="text-body font-medium text-secondary">Dyslexie-freundliche Schrift</span>
<p className="text-small text-tertiary">Verwendet eine besser unterscheidbare Schriftart</p>
</div>
</label>
</div>
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Über SkillMate
</h2>
<div className="space-y-2 text-body">
<p className="text-secondary">
<span className="font-medium">Version:</span> 1.0.0
</p>
<p className="text-secondary">
<span className="font-medium">Entwickelt für:</span> Sicherheitsbehörden
</p>
<p className="text-small text-tertiary mt-4">
SkillMate ist eine spezialisierte Anwendung zur Verwaltung von
Mitarbeiterfähigkeiten und -kompetenzen in Sicherheitsbehörden.
</p>
</div>
</div>
<div className="flex justify-end space-x-3">
<button className="btn-secondary">
Abbrechen
</button>
<button onClick={handleSave} className="btn-primary">
Einstellungen speichern
</button>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,324 @@
import { useEffect, useState } from 'react'
import type { Employee } from '@skillmate/shared'
import { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard'
import { useNavigate } from 'react-router-dom'
import { employeeApi } from '../services/api'
export default function SkillSearch() {
const navigate = useNavigate()
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedSubCategory, setSelectedSubCategory] = useState('')
const [selectedSkills, setSelectedSkills] = useState<string[]>([])
const [freeSearchTerm, setFreeSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<Employee[]>([])
const [hasSearched, setHasSearched] = useState(false)
const [loading, setLoading] = useState(false)
const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
useEffect(() => {
const load = async () => {
try {
const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
})
const data = await res.json()
if (data?.success) setHierarchy(data.data || [])
} catch (e) { /* ignore */ }
}
load()
}, [])
const handleCategoryChange = (categoryId: string) => {
setSelectedCategory(categoryId)
setSelectedSubCategory('')
setSelectedSkills([])
}
const handleSubCategoryChange = (subCategoryId: string) => {
setSelectedSubCategory(subCategoryId)
setSelectedSkills([])
}
const handleSkillToggle = (skillId: string) => {
setSelectedSkills(prev =>
prev.includes(skillId)
? prev.filter(s => s !== skillId)
: [...prev, skillId]
)
}
const handleSearch = async () => {
setLoading(true)
setHasSearched(true)
try {
// Get all employees first
const allEmployees = await employeeApi.getAll()
// Filter based on search criteria
let results = [...allEmployees]
// Filter by free search term
if (freeSearchTerm) {
const searchLower = freeSearchTerm.toLowerCase()
results = results.filter(employee => {
// Search in name, department, position
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
employee.department.toLowerCase().includes(searchLower) ||
employee.position.toLowerCase().includes(searchLower)) {
return true
}
// Search in skills
if (employee.skills?.some((skill: any) => skill.name.toLowerCase().includes(searchLower))) {
return true
}
// Search in languages
if (employee.languages?.some((lang: any) => {
if (!lang) return false
return lang.language.toLowerCase().includes(searchLower)
})) {
return true
}
// Search in specializations
if (employee.specializations?.some((spec: any) => spec.toLowerCase().includes(searchLower))) {
return true
}
return false
})
}
// Filter by selected skills (IDs)
if (selectedSkills.length > 0) {
results = results.filter(employee => {
return selectedSkills.some(skillId => {
// Check in skills array by ID
if (employee.skills?.some((skill: any) => skill.id === skillId)) {
return true
}
// Ignore languages/specializations here (catalog-driven search)
return false
})
})
}
setSearchResults(results)
} catch (error) {
console.error('Search failed:', error)
setSearchResults([])
} finally {
setLoading(false)
}
}
const handleReset = () => {
setSelectedCategory('')
setSelectedSubCategory('')
setSelectedSkills([])
setFreeSearchTerm('')
setSearchResults([])
setHasSearched(false)
}
const getSubCategories = () => {
if (!selectedCategory) return []
const category = hierarchy.find(cat => cat.id === selectedCategory)
return category?.subcategories || []
}
const getSkills = () => {
if (!selectedCategory || !selectedSubCategory) return []
const category = hierarchy.find(cat => cat.id === selectedCategory)
const subCategory = category?.subcategories.find(sub => sub.id === selectedSubCategory)
return subCategory?.skills || []
}
const getSkillNameById = (skillId: string) => {
for (const cat of hierarchy) {
for (const sub of cat.subcategories || []) {
const found = (sub.skills || []).find(s => s.id === skillId)
if (found) return found.name
}
}
return skillId
}
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
Skill-Suche
</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Suchkriterien
</h2>
<div className="space-y-4">
{/* Freie Suche */}
<div>
<label className="block text-body font-medium text-secondary mb-2">
Freie Suche
</label>
<div className="relative">
<input
type="text"
value={freeSearchTerm}
onChange={(e) => setFreeSearchTerm(e.target.value)}
placeholder="Name, Skill, Abteilung..."
className="input-field w-full pl-10"
/>
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-placeholder" />
</div>
</div>
{/* Kategorie-Auswahl */}
<div>
<label className="block text-body font-medium text-secondary mb-2">
Skill-Kategorie
</label>
<select
value={selectedCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
className="input-field w-full"
>
<option value="">Alle Kategorien</option>
{hierarchy.map((category) => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
</div>
{/* Unterkategorie-Auswahl */}
{selectedCategory && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Unterkategorie
</label>
<select
value={selectedSubCategory}
onChange={(e) => handleSubCategoryChange(e.target.value)}
className="input-field w-full"
>
<option value="">Alle Unterkategorien</option>
{getSubCategories().map((subCategory) => (
<option key={subCategory.id} value={subCategory.id}>{subCategory.name}</option>
))}
</select>
</div>
)}
{/* Skill-Auswahl */}
{selectedSubCategory && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Skills auswählen
</label>
<div className="space-y-2 max-h-64 overflow-y-auto border border-border-default rounded-card p-3">
{getSkills().map((skill) => (
<label key={skill.id} className="flex items-center space-x-2 cursor-pointer hover:bg-bg-accent p-1 rounded">
<input
type="checkbox"
checked={selectedSkills.includes(skill.id)}
onChange={() => handleSkillToggle(skill.id)}
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body text-secondary">{skill.name}</span>
</label>
))}
</div>
</div>
)}
<div className="flex space-x-2 pt-4">
<button
onClick={handleSearch}
disabled={loading || (!freeSearchTerm && selectedSkills.length === 0)}
className="flex-1 btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
<SearchIcon className="w-5 h-5 mr-2" />
{loading ? 'Suche läuft...' : 'Suchen'}
</button>
<button
onClick={handleReset}
className="btn-secondary"
>
Zurücksetzen
</button>
</div>
</div>
</div>
{selectedSkills.length > 0 && (
<div className="card mt-6">
<h3 className="text-body font-poppins font-semibold text-primary mb-3">
Ausgewählte Skills ({selectedSkills.length})
</h3>
<div className="flex flex-wrap gap-2">
{selectedSkills.map((skillId) => (
<span key={skillId} className="badge badge-info">
{getSkillNameById(skillId)}
<button
onClick={() => handleSkillToggle(skillId)}
className="ml-2 text-xs hover:text-error"
>
×
</button>
</span>
))}
</div>
</div>
)}
</div>
<div className="lg:col-span-2">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Suchergebnisse
{hasSearched && searchResults.length > 0 && (
<span className="ml-2 text-body font-normal text-tertiary">
({searchResults.length} Mitarbeiter gefunden)
</span>
)}
</h2>
{!hasSearched ? (
<div className="text-center py-12">
<SearchIcon className="w-16 h-16 mx-auto text-text-placeholder mb-4" />
<p className="text-tertiary">
Geben Sie einen Suchbegriff ein oder wählen Sie Skills aus
</p>
</div>
) : loading ? (
<div className="text-center py-12">
<p className="text-tertiary">Suche läuft...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12">
<p className="text-tertiary">
Keine Mitarbeiter gefunden
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{searchResults.map((employee) => (
<EmployeeCard
key={employee.id}
employee={employee}
onClick={() => navigate(`/employees/${employee.id}`)}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,289 @@
import { useEffect, useMemo, useState } from 'react'
import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api'
type Hierarchy = { id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }
type TeamSlot = {
id: string
title: string
selectedCategory: string
selectedSubCategory: string
selectedSkills: string[]
assignedEmployeeId?: string
}
export default function TeamZusammenstellung() {
const [hierarchy, setHierarchy] = useState<Hierarchy[]>([])
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [slots, setSlots] = useState<TeamSlot[]>([createSlot()])
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const toPublic = (p?: string | null) => (p && p.startsWith('/uploads/')) ? `${PUBLIC_BASE}${p}` : (p || '')
useEffect(() => {
const load = async () => {
setLoading(true)
setError('')
try {
const [empList, hier] = await Promise.all([
employeeApi.getAll(),
fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}).then(r => r.json())
])
setEmployees(empList || [])
if (hier?.success) setHierarchy(hier.data || [])
} catch (e) {
setError('Daten konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
load()
}, [])
function createSlot(): TeamSlot {
return {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
title: '',
selectedCategory: '',
selectedSubCategory: '',
selectedSkills: [],
}
}
const getSubCategories = (slot: TeamSlot) => {
if (!slot.selectedCategory) return []
const cat = hierarchy.find(c => c.id === slot.selectedCategory)
return cat?.subcategories || []
}
const getSkills = (slot: TeamSlot) => {
if (!slot.selectedCategory || !slot.selectedSubCategory) return []
const cat = hierarchy.find(c => c.id === slot.selectedCategory)
const sub = cat?.subcategories.find(s => s.id === slot.selectedSubCategory)
return sub?.skills || []
}
function toggleSkill(slotId: string, skillId: string) {
setSlots(prev => prev.map(s => s.id === slotId ? {
...s,
selectedSkills: s.selectedSkills.includes(skillId)
? s.selectedSkills.filter(x => x !== skillId)
: [...s.selectedSkills, skillId]
} : s))
}
function updateSlot(slotId: string, patch: Partial<TeamSlot>) {
setSlots(prev => prev.map(s => s.id === slotId ? {
...s,
...patch,
...(patch.selectedCategory !== undefined ? { selectedSubCategory: '', selectedSkills: [] } : {}),
...(patch.selectedSubCategory !== undefined ? { selectedSkills: [] } : {}),
} : s))
}
function removeSlot(slotId: string) {
setSlots(prev => prev.filter(s => s.id !== slotId))
}
function addSlot() {
setSlots(prev => [...prev, createSlot()])
}
function assign(slotId: string, employeeId: string) {
setSlots(prev => prev.map(s => s.id === slotId ? { ...s, assignedEmployeeId: employeeId } : s))
}
function unassign(slotId: string) {
setSlots(prev => prev.map(s => s.id === slotId ? { ...s, assignedEmployeeId: undefined } : s))
}
const employeesById = useMemo(() => {
const map = new Map<string, Employee>()
for (const e of employees) map.set(e.id, e)
return map
}, [employees])
function getSuggestions(slot: TeamSlot): Employee[] {
if (employees.length === 0) return []
if (slot.selectedSkills.length === 0) return employees
return employees
.map(e => ({
e,
matchCount: slot.selectedSkills.filter(id => e.skills?.some(s => s.id === id)).length
}))
.filter(x => x.matchCount > 0)
.sort((a, b) => b.matchCount - a.matchCount)
.slice(0, 6)
.map(x => x.e)
}
const getSkillNameById = (skillId: string) => {
for (const cat of hierarchy) {
for (const sub of cat.subcategories || []) {
const found = (sub.skills || []).find(s => s.id === skillId)
if (found) return found.name
}
}
return skillId
}
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">Team-Zusammenstellung</h1>
<div className="mb-4 flex items-center justify-between">
<p className="text-tertiary">Erstellen Sie leere Mitarbeiterkarten, definieren Sie Funktion und benötigte Skills und weisen Sie passende Mitarbeitende zu.</p>
<button className="btn-primary" onClick={addSlot}>+ Karte hinzufügen</button>
</div>
{error && <div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>}
{loading ? (
<div className="card"><p className="text-tertiary">Lade Daten</p></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{slots.map((slot) => {
const assigned = slot.assignedEmployeeId ? employeesById.get(slot.assignedEmployeeId) : undefined
const suggestions = getSuggestions(slot)
return (
<div key={slot.id} className="card">
<div className="flex items-start justify-between mb-3">
<h3 className="text-title-card font-semibold text-primary">Mitarbeiter-Karte</h3>
<button className="btn-secondary h-8 px-3" onClick={() => removeSlot(slot.id)}>Entfernen</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-body font-medium text-secondary mb-1">Funktion / Rolle</label>
<input className="input-field w-full" placeholder="z. B. Einsatzleitung" value={slot.title} onChange={(e) => updateSlot(slot.id, { title: e.target.value })} />
</div>
{/* Wenn zugewiesen: Kontakt + Bild anzeigen; sonst Auswahl anzeigen */}
{assigned ? (
<div className="flex items-start gap-3 p-3 border rounded-input bg-bg-accent">
<div className="w-16 h-16 rounded-full bg-bg-accent overflow-hidden flex items-center justify-center">
{assigned.photo ? (
<img src={toPublic(assigned.photo)} alt="Foto" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-primary-blue font-semibold">
{assigned.firstName.charAt(0)}{assigned.lastName.charAt(0)}
</div>
)}
</div>
<div className="flex-1">
<div className="font-medium text-primary">{assigned.firstName} {assigned.lastName}</div>
<div className="text-small text-tertiary">{assigned.department} {assigned.position}</div>
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 text-small">
<div>
<span className="text-tertiary">E-Mail:</span>
<div className="text-secondary break-all">{assigned.email || '—'}</div>
</div>
<div>
<span className="text-tertiary">Telefon:</span>
<div className="text-secondary">{assigned.phone || '—'}</div>
</div>
{assigned.mobile && (
<div>
<span className="text-tertiary">Mobil:</span>
<div className="text-secondary">{assigned.mobile}</div>
</div>
)}
{assigned.office && (
<div>
<span className="text-tertiary">Büro:</span>
<div className="text-secondary">{assigned.office}</div>
</div>
)}
</div>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-small font-medium text-secondary mb-1">Kategorie</label>
<select className="input-field w-full" value={slot.selectedCategory} onChange={(e) => updateSlot(slot.id, { selectedCategory: e.target.value })}>
<option value=""></option>
{hierarchy.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-small font-medium text-secondary mb-1">Unterkategorie</label>
<select className="input-field w-full" value={slot.selectedSubCategory} onChange={(e) => updateSlot(slot.id, { selectedSubCategory: e.target.value })} disabled={!slot.selectedCategory}>
<option value=""></option>
{getSubCategories(slot).map(sc => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
</div>
</div>
{slot.selectedSubCategory && (
<div>
<label className="block text-small font-medium text-secondary mb-1">Benötigte Skills</label>
<div className="space-y-1 max-h-40 overflow-auto border border-border-default rounded-input p-2">
{getSkills(slot).map(sk => (
<label key={sk.id} className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4" checked={slot.selectedSkills.includes(sk.id)} onChange={() => toggleSkill(slot.id, sk.id)} />
<span className="text-body text-secondary">{sk.name}</span>
</label>
))}
{getSkills(slot).length === 0 && (
<p className="text-small text-tertiary">Keine Skills in dieser Unterkategorie</p>
)}
</div>
{slot.selectedSkills.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{slot.selectedSkills.map(id => (
<span key={id} className="badge badge-info">{getSkillNameById(id)}</span>
))}
</div>
)}
</div>
)}
</>
)}
<div>
<label className="block text-body font-medium text-secondary mb-2">Vorschläge</label>
{assigned ? (
<div className="flex items-center justify-between">
<span className="text-small text-tertiary">Mitarbeiter zugewiesen</span>
<button className="btn-secondary h-8 px-3" onClick={() => unassign(slot.id)}>Zuweisung entfernen</button>
</div>
) : (
<div className="space-y-2 max-h-48 overflow-auto">
{suggestions.length === 0 ? (
<p className="text-small text-tertiary">Keine passenden Mitarbeitenden gefunden</p>
) : suggestions.map(e => (
<div key={e.id} className="p-2 border rounded-input flex items-center justify-between">
<div>
<div className="font-medium text-secondary">{e.firstName} {e.lastName}</div>
<div className="text-small text-tertiary">{e.department} {e.position}</div>
</div>
<button className="btn-primary h-8 px-3" onClick={() => assign(slot.id, e.id)}>Zuweisen</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
})}
<button onClick={addSlot} className="border-2 border-dashed border-border-default rounded-card flex items-center justify-center p-6 hover:bg-bg-accent">
<div className="text-secondary flex items-center">
<span className="text-xl mr-2">+</span> Karte hinzufügen
</div>
</button>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,320 @@
import React, { useState, useEffect } from 'react'
import { api } from '../services/api'
import { useAuthStore } from '../stores/authStore'
interface AnalyticsOverview {
workspace_stats: Array<{ type: string; count: number }>
booking_stats: {
total_bookings: number
unique_users: number
completed_bookings: number
cancelled_bookings: number
no_shows: number
}
utilization_by_type: Array<{
type: string
avg_utilization: number
avg_hours_booked: number
}>
popular_workspaces: Array<{
id: string
name: string
type: string
floor: string
booking_count: number
avg_duration_hours: number
}>
peak_hours: Array<{
hour: number
booking_count: number
}>
date_range: {
from: string
to: string
}
}
export default function WorkspaceAnalytics() {
const [analytics, setAnalytics] = useState<AnalyticsOverview | null>(null)
const [fromDate, setFromDate] = useState(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
)
const [toDate, setToDate] = useState(new Date().toISOString().split('T')[0])
const [loading, setLoading] = useState(true)
const { user } = useAuthStore()
useEffect(() => {
if (user && ['admin', 'poweruser'].includes(user.role)) {
loadAnalytics()
}
}, [fromDate, toDate, user])
const loadAnalytics = async () => {
try {
setLoading(true)
const response = await api.get('/analytics/overview', {
params: { from_date: fromDate, to_date: toDate }
})
setAnalytics(response.data)
} catch (error) {
console.error('Failed to load analytics:', error)
} finally {
setLoading(false)
}
}
if (!user || !['admin', 'poweruser'].includes(user.role)) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200">
Sie haben keine Berechtigung, diese Seite zu sehen.
</p>
</div>
</div>
)
}
const getWorkspaceTypeLabel = (type: string) => {
const labels: Record<string, string> = {
desk: 'Arbeitsplätze',
meeting_room: 'Meetingräume',
phone_booth: 'Telefonboxen',
parking: 'Parkplätze',
locker: 'Schließfächer'
}
return labels[type] || type
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Workplace Analytics
</h1>
<p className="text-gray-600 dark:text-gray-400">
Detaillierte Auslastungsstatistiken und Nutzungsanalysen
</p>
</div>
{/* Date Range Filter */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Von
</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Bis
</label>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div className="flex items-end">
<button
onClick={loadAnalytics}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Aktualisieren
</button>
</div>
</div>
</div>
{loading ? (
<div className="text-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : analytics ? (
<>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Gesamtbuchungen</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{analytics.booking_stats.total_bookings}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Aktive Nutzer</p>
<p className="text-3xl font-bold text-blue-600 mt-2">
{analytics.booking_stats.unique_users}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Abgeschlossen</p>
<p className="text-3xl font-bold text-green-600 mt-2">
{analytics.booking_stats.completed_bookings}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Storniert</p>
<p className="text-3xl font-bold text-orange-600 mt-2">
{analytics.booking_stats.cancelled_bookings}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">No-Shows</p>
<p className="text-3xl font-bold text-red-600 mt-2">
{analytics.booking_stats.no_shows}
</p>
</div>
</div>
{/* Workspace Distribution */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Arbeitsplätze nach Typ
</h2>
<div className="space-y-3">
{analytics.workspace_stats.map(stat => (
<div key={stat.type} className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300">
{getWorkspaceTypeLabel(stat.type)}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{stat.count}
</span>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Durchschnittliche Auslastung
</h2>
<div className="space-y-3">
{analytics.utilization_by_type.map(util => (
<div key={util.type}>
<div className="flex justify-between items-center mb-1">
<span className="text-gray-700 dark:text-gray-300">
{getWorkspaceTypeLabel(util.type)}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{Math.round(util.avg_utilization * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.round(util.avg_utilization * 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
{/* Popular Workspaces */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Beliebteste Arbeitsplätze
</h2>
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Typ
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Etage
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Buchungen
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Ø Dauer
</th>
</tr>
</thead>
<tbody>
{analytics.popular_workspaces.map(workspace => (
<tr
key={workspace.id}
className="border-b border-gray-200 dark:border-gray-700"
>
<td className="py-3 px-4 text-gray-900 dark:text-white">
{workspace.name}
</td>
<td className="py-3 px-4 text-gray-600 dark:text-gray-400">
{getWorkspaceTypeLabel(workspace.type)}
</td>
<td className="py-3 px-4 text-gray-600 dark:text-gray-400">
{workspace.floor}
</td>
<td className="py-3 px-4 text-right text-gray-900 dark:text-white">
{workspace.booking_count}
</td>
<td className="py-3 px-4 text-right text-gray-600 dark:text-gray-400">
{workspace.avg_duration_hours.toFixed(1)}h
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Peak Hours */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Stoßzeiten
</h2>
<div className="h-64">
<div className="flex items-end justify-between h-full">
{Array.from({ length: 24 }, (_, i) => {
const hourData = analytics.peak_hours.find(h => h.hour === i)
const count = hourData?.booking_count || 0
const maxCount = Math.max(...analytics.peak_hours.map(h => h.booking_count))
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
return (
<div
key={i}
className="flex-1 flex flex-col items-center"
>
<div
className="w-full bg-blue-600 rounded-t"
style={{ height: `${height}%` }}
title={`${i}:00 - ${count} Buchungen`}
/>
<span className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{i}
</span>
</div>
)
})}
</div>
</div>
</div>
</>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-gray-600 dark:text-gray-400 text-center">
Keine Daten verfügbar
</p>
</div>
)}
</div>
)
}

107
frontend/tailwind.config.js Normale Datei
Datei anzeigen

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

24
frontend/tsconfig.json Normale Datei
Datei anzeigen

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

10
frontend/tsconfig.node.json Normale Datei
Datei anzeigen

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

Datei anzeigen

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

26
frontend/vite.config.ts Normale Datei
Datei anzeigen

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
base: './',
define: {
// Define process globally to avoid "process is not defined" errors
'process.env': {},
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
'process.platform': JSON.stringify('win32')
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
external: ['electron'],
}
},
})