Initial commit
Dieser Commit ist enthalten in:
91
frontend/electron-builder.json
Normale Datei
91
frontend/electron-builder.json
Normale Datei
@ -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
210
frontend/electron/main.js
Normale Datei
@ -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
21
frontend/electron/preload.js
Normale Datei
@ -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)
|
||||
}
|
||||
})
|
||||
5
frontend/electron/renderer-preload.js
Normale Datei
5
frontend/electron/renderer-preload.js
Normale Datei
@ -0,0 +1,5 @@
|
||||
// Inject process object for renderer
|
||||
window.process = {
|
||||
env: {},
|
||||
platform: 'win32'
|
||||
}
|
||||
20
frontend/index-electron.html
Normale Datei
20
frontend/index-electron.html
Normale Datei
@ -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
13
frontend/index.html
Normale Datei
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SkillMate - Mitarbeiter-Skills-Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
79
frontend/installer/skillmate-setup.iss
Normale Datei
79
frontend/installer/skillmate-setup.iss
Normale Datei
@ -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
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
31
frontend/package.json
Normale Datei
@ -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
6
frontend/postcss.config.js
Normale Datei
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
30
frontend/public/debug.html
Normale Datei
30
frontend/public/debug.html
Normale Datei
@ -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
11
frontend/public/icon.svg
Normale Datei
@ -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
120
frontend/src/App.tsx
Normale Datei
@ -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
|
||||
90
frontend/src/components/EmployeeCard.tsx
Normale Datei
90
frontend/src/components/EmployeeCard.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/Header.tsx
Normale Datei
157
frontend/src/components/Header.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/Layout.tsx
Normale Datei
21
frontend/src/components/Layout.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
144
frontend/src/components/PhotoPreview.tsx
Normale Datei
144
frontend/src/components/PhotoPreview.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
227
frontend/src/components/PhotoUpload.tsx
Normale Datei
227
frontend/src/components/PhotoUpload.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/Sidebar.tsx
Normale Datei
55
frontend/src/components/Sidebar.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/SkillLevelBar.tsx
Normale Datei
77
frontend/src/components/SkillLevelBar.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/WindowControls.tsx
Normale Datei
59
frontend/src/components/WindowControls.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
18
frontend/src/components/icons/ChartIcon.tsx
Normale Datei
18
frontend/src/components/icons/ChartIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
18
frontend/src/components/icons/DeskIcon.tsx
Normale Datei
18
frontend/src/components/icons/DeskIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/HomeIcon.tsx
Normale Datei
7
frontend/src/components/icons/HomeIcon.tsx
Normale Datei
@ -0,0 +1,7 @@
|
||||
export default function HomeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
18
frontend/src/components/icons/MapIcon.tsx
Normale Datei
18
frontend/src/components/icons/MapIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/MoonIcon.tsx
Normale Datei
7
frontend/src/components/icons/MoonIcon.tsx
Normale Datei
@ -0,0 +1,7 @@
|
||||
export default function MoonIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/SearchIcon.tsx
Normale Datei
7
frontend/src/components/icons/SearchIcon.tsx
Normale Datei
@ -0,0 +1,7 @@
|
||||
export default function SearchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
8
frontend/src/components/icons/SettingsIcon.tsx
Normale Datei
8
frontend/src/components/icons/SettingsIcon.tsx
Normale Datei
@ -0,0 +1,8 @@
|
||||
export default function SettingsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/SunIcon.tsx
Normale Datei
7
frontend/src/components/icons/SunIcon.tsx
Normale Datei
@ -0,0 +1,7 @@
|
||||
export default function SunIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/UsersIcon.tsx
Normale Datei
7
frontend/src/components/icons/UsersIcon.tsx
Normale Datei
@ -0,0 +1,7 @@
|
||||
export default function UsersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
9
frontend/src/components/icons/index.ts
Normale Datei
9
frontend/src/components/icons/index.ts
Normale Datei
@ -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'
|
||||
97
frontend/src/hooks/usePermissions.ts
Normale Datei
97
frontend/src/hooks/usePermissions.ts
Normale Datei
@ -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
10
frontend/src/main.tsx
Normale Datei
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
66
frontend/src/services/api.ts
Normale Datei
66
frontend/src/services/api.ts
Normale Datei
@ -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
|
||||
26
frontend/src/stores/authStore.ts
Normale Datei
26
frontend/src/stores/authStore.ts
Normale Datei
@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '@skillmate/shared'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
login: (user: User, token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
login: (user, token) => set({ user, token, isAuthenticated: true }),
|
||||
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
21
frontend/src/stores/themeStore.ts
Normale Datei
21
frontend/src/stores/themeStore.ts
Normale Datei
@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface ThemeState {
|
||||
isDarkMode: boolean
|
||||
toggleTheme: () => void
|
||||
setTheme: (isDark: boolean) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isDarkMode: false,
|
||||
toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
|
||||
setTheme: (isDark) => set({ isDarkMode: isDark }),
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
203
frontend/src/styles/index.css
Normale Datei
203
frontend/src/styles/index.css
Normale Datei
@ -0,0 +1,203 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--primary-blue: #3182CE;
|
||||
--primary-blue-hover: #2563EB;
|
||||
--primary-blue-active: #1D4ED8;
|
||||
--primary-blue-dark: #1E40AF;
|
||||
--bg-main: #F8FAFC;
|
||||
--bg-white: #FFFFFF;
|
||||
--bg-gray: #F0F4F8;
|
||||
--bg-accent: #E6F2FF;
|
||||
--text-primary: #1A365D;
|
||||
--text-secondary: #2D3748;
|
||||
--text-tertiary: #4A5568;
|
||||
--text-quaternary: #718096;
|
||||
--text-placeholder: #A0AEC0;
|
||||
--border-default: #E2E8F0;
|
||||
--border-input: #CBD5E0;
|
||||
--success: #059669;
|
||||
--warning: #D97706;
|
||||
--error: #DC2626;
|
||||
--info: #2563EB;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary-blue: #232D53;
|
||||
--primary-blue-hover: #232D53;
|
||||
--primary-blue-active: #232D53;
|
||||
--bg-main: #000000;
|
||||
--bg-white: #1A1F3A;
|
||||
--bg-gray: #232D53;
|
||||
--bg-accent: #232D53;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.6);
|
||||
--text-quaternary: rgba(255, 255, 255, 0.5);
|
||||
--text-placeholder: rgba(255, 255, 255, 0.4);
|
||||
--border-default: rgba(255, 255, 255, 0.1);
|
||||
--border-input: rgba(255, 255, 255, 0.2);
|
||||
--success: #4CAF50;
|
||||
--warning: #FFC107;
|
||||
--error: #FF4444;
|
||||
--info: #2196F3;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-colors duration-default ease-default;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg-main text-text-secondary font-sans;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-dark-bg text-dark-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-blue text-white rounded-button h-12 px-8 font-poppins font-semibold text-nav
|
||||
hover:bg-primary-blue-hover active:bg-primary-blue-active shadow-sm
|
||||
transition-all duration-default ease-default
|
||||
dark:bg-dark-accent dark:text-dark-bg dark:hover:bg-dark-accent-hover dark:hover:text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-transparent text-text-primary border border-border-default rounded-button h-12 px-8
|
||||
font-poppins font-semibold text-nav hover:bg-bg-main hover:border-border-input
|
||||
transition-all duration-default ease-default
|
||||
dark:text-white dark:border-dark-primary dark:hover:bg-dark-primary;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply bg-white border border-border-input rounded-input px-4 py-3 text-text-secondary
|
||||
placeholder:text-text-placeholder focus:border-primary-blue focus:shadow-focus
|
||||
focus:outline-none transition-all duration-fast
|
||||
dark:bg-dark-primary dark:border-transparent dark:text-white
|
||||
dark:placeholder:text-dark-text-tertiary dark:focus:bg-dark-bg-focus;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white border border-border-default rounded-card p-card shadow-sm
|
||||
hover:border-primary-blue hover:bg-bg-main hover:shadow-md hover:-translate-y-0.5
|
||||
transition-all duration-default ease-default cursor-pointer
|
||||
dark:bg-dark-bg-secondary dark:border-transparent dark:hover:border-dark-accent
|
||||
dark:hover:bg-dark-primary;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
@apply bg-white border border-gray-300 rounded-lg p-6 shadow-sm
|
||||
dark:bg-gray-800 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply px-3 py-1 rounded-badge text-help font-semibold uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success-bg text-success dark:bg-success dark:text-white;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning-bg text-warning dark:bg-warning dark:text-dark-bg;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply bg-error-bg text-error dark:bg-error dark:text-white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-info-bg text-info dark:bg-info dark:text-white;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@apply flex items-center px-4 py-3 rounded-input text-nav font-medium
|
||||
hover:bg-bg-main transition-all duration-fast cursor-pointer
|
||||
dark:hover:bg-dark-primary/50;
|
||||
}
|
||||
|
||||
.sidebar-item-active {
|
||||
@apply bg-bg-accent text-primary-blue-dark border-l-4 border-primary-blue
|
||||
dark:bg-dark-primary dark:text-white dark:border-dark-accent;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply bg-white border border-border-default rounded-input shadow-xl
|
||||
dark:bg-dark-bg dark:border-dark-border;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply bg-bg-gray h-10 flex items-center justify-between px-4 rounded-t-input
|
||||
dark:bg-dark-primary;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply bg-bg-gray text-text-primary font-semibold border-b border-border-default
|
||||
dark:bg-dark-primary dark:text-white dark:border-dark-border;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply bg-white hover:bg-bg-main border-b border-border-default transition-colors duration-fast
|
||||
dark:bg-transparent dark:hover:bg-dark-bg-secondary dark:border-dark-border;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
@apply scrollbar-thin scrollbar-track-divider scrollbar-thumb-border-input
|
||||
hover:scrollbar-thumb-text-placeholder
|
||||
dark:scrollbar-track-dark-bg-secondary dark:scrollbar-thumb-dark-accent
|
||||
dark:hover:scrollbar-thumb-dark-accent-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-primary {
|
||||
@apply text-text-primary dark:text-white;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
@apply text-text-secondary dark:text-dark-text-secondary;
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
@apply text-text-tertiary dark:text-dark-text-tertiary;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
@apply bg-bg-main dark:bg-dark-bg;
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
@apply bg-white dark:bg-dark-bg-secondary;
|
||||
}
|
||||
|
||||
.bg-tertiary {
|
||||
@apply bg-bg-gray dark:bg-dark-primary;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
@apply border-border-default dark:border-dark-border;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.-webkit-app-region-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.-webkit-app-region-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
23
frontend/src/temp/skills.ts
Normale Datei
23
frontend/src/temp/skills.ts
Normale Datei
@ -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
22
frontend/src/types/electron.d.ts
vendored
Normale Datei
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
270
frontend/src/views/Dashboard.tsx
Normale Datei
270
frontend/src/views/Dashboard.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
260
frontend/src/views/DeskBooking.tsx
Normale Datei
260
frontend/src/views/DeskBooking.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
232
frontend/src/views/EmployeeDetail.tsx
Normale Datei
232
frontend/src/views/EmployeeDetail.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
684
frontend/src/views/EmployeeForm.tsx
Normale Datei
684
frontend/src/views/EmployeeForm.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
197
frontend/src/views/EmployeeList.tsx
Normale Datei
197
frontend/src/views/EmployeeList.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
251
frontend/src/views/FloorPlan.tsx
Normale Datei
251
frontend/src/views/FloorPlan.tsx
Normale Datei
@ -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
91
frontend/src/views/Login.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
255
frontend/src/views/MyProfile.tsx
Normale Datei
255
frontend/src/views/MyProfile.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
685
frontend/src/views/ProfileEdit.tsx
Normale Datei
685
frontend/src/views/ProfileEdit.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
426
frontend/src/views/ProfileSearch.tsx
Normale Datei
426
frontend/src/views/ProfileSearch.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
137
frontend/src/views/Settings.tsx
Normale Datei
137
frontend/src/views/Settings.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
324
frontend/src/views/SkillSearch.tsx
Normale Datei
324
frontend/src/views/SkillSearch.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
289
frontend/src/views/TeamZusammenstellung.tsx
Normale Datei
289
frontend/src/views/TeamZusammenstellung.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
320
frontend/src/views/WorkspaceAnalytics.tsx
Normale Datei
320
frontend/src/views/WorkspaceAnalytics.tsx
Normale Datei
@ -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
107
frontend/tailwind.config.js
Normale Datei
@ -0,0 +1,107 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Light Mode Colors
|
||||
'primary-blue': '#3182CE',
|
||||
'primary-blue-hover': '#2563EB',
|
||||
'primary-blue-active': '#1D4ED8',
|
||||
'primary-blue-dark': '#1E40AF',
|
||||
'bg-main': '#F8FAFC',
|
||||
'bg-white': '#FFFFFF',
|
||||
'bg-gray': '#F0F4F8',
|
||||
'bg-accent': '#E6F2FF',
|
||||
'text-primary': '#1A365D',
|
||||
'text-secondary': '#2D3748',
|
||||
'text-tertiary': '#4A5568',
|
||||
'text-quaternary': '#718096',
|
||||
'text-placeholder': '#A0AEC0',
|
||||
'border-default': '#E2E8F0',
|
||||
'border-input': '#CBD5E0',
|
||||
'divider': '#F1F5F9',
|
||||
'success': '#059669',
|
||||
'success-bg': '#D1FAE5',
|
||||
'warning': '#D97706',
|
||||
'warning-bg': '#FEF3C7',
|
||||
'error': '#DC2626',
|
||||
'error-bg': '#FEE2E2',
|
||||
'info': '#2563EB',
|
||||
'info-bg': '#DBEAFE',
|
||||
|
||||
// Dark Mode Colors
|
||||
'dark': {
|
||||
'primary': '#232D53',
|
||||
'accent': '#00D4FF',
|
||||
'accent-hover': '#00B8E6',
|
||||
'bg': '#000000',
|
||||
'bg-secondary': '#1A1F3A',
|
||||
'bg-sidebar': '#0A0A0A',
|
||||
'bg-hover': '#232D53',
|
||||
'bg-focus': '#2A3560',
|
||||
'text-primary': '#FFFFFF',
|
||||
'text-secondary': 'rgba(255, 255, 255, 0.7)',
|
||||
'text-tertiary': 'rgba(255, 255, 255, 0.6)',
|
||||
'border': 'rgba(255, 255, 255, 0.1)',
|
||||
'success': '#4CAF50',
|
||||
'warning': '#FFC107',
|
||||
'error': '#FF4444',
|
||||
'info': '#2196F3',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'poppins': ['Poppins', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
|
||||
'sans': ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Arial', 'sans-serif'],
|
||||
'mono': ['SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'title-lg': '32px',
|
||||
'title-dialog': '24px',
|
||||
'title-card': '20px',
|
||||
'nav': '15px',
|
||||
'body': '14px',
|
||||
'small': '13px',
|
||||
'help': '12px',
|
||||
},
|
||||
spacing: {
|
||||
'container': '40px',
|
||||
'card': '32px',
|
||||
'element': '16px',
|
||||
'inline': '8px',
|
||||
},
|
||||
borderRadius: {
|
||||
'card': '16px',
|
||||
'button': '24px',
|
||||
'input': '8px',
|
||||
'badge': '12px',
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
'md': '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
'lg': '0 10px 15px rgba(0, 0, 0, 0.1)',
|
||||
'xl': '0 20px 25px rgba(0, 0, 0, 0.1)',
|
||||
'focus': '0 0 0 3px rgba(49, 130, 206, 0.1)',
|
||||
'dark-sm': '0 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
'dark-md': '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
'dark-lg': '0 8px 24px rgba(0, 0, 0, 0.5)',
|
||||
'dark-glow': '0 0 20px rgba(0, 212, 255, 0.3)',
|
||||
},
|
||||
transitionProperty: {
|
||||
'all': 'all',
|
||||
},
|
||||
transitionDuration: {
|
||||
'default': '300ms',
|
||||
'fast': '200ms',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'default': 'ease',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
frontend/tsconfig.json
Normale Datei
24
frontend/tsconfig.json
Normale Datei
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normale Datei
10
frontend/tsconfig.node.json
Normale Datei
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
20
frontend/vite.config.electron.ts
Normale Datei
20
frontend/vite.config.electron.ts
Normale Datei
@ -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
26
frontend/vite.config.ts
Normale Datei
@ -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'],
|
||||
}
|
||||
},
|
||||
})
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren