So mit neuen UI Ideen und so

Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-22 20:54:57 +02:00
Ursprung 6b9b6d4f20
Commit 26f95d2e4a
29 geänderte Dateien mit 5321 neuen und 1325 gelöschten Zeilen

Datei anzeigen

@ -37,8 +37,10 @@
"Bash(set PORT=5000)", "Bash(set PORT=5000)",
"Bash(set PORT=3005)", "Bash(set PORT=3005)",
"Bash(set FIELD_ENCRYPTION_KEY=dev_field_encryption_key_32chars_min!)", "Bash(set FIELD_ENCRYPTION_KEY=dev_field_encryption_key_32chars_min!)",
"Bash(start:*)" "Bash(start:*)",
"WebSearch"
], ],
"deny": [] "deny": [],
"defaultMode": "acceptEdits"
} }
} }

Datei anzeigen

@ -5,9 +5,9 @@
## Project Overview ## Project Overview
- **Path**: `A:/GiTea/SkillMate` - **Path**: `A:/GiTea/SkillMate`
- **Files**: 222 files - **Files**: 240 files
- **Size**: 10.5 MB - **Size**: 6.7 MB
- **Last Modified**: 2025-09-18 22:20 - **Last Modified**: 2025-09-21 16:48
## Technology Stack ## Technology Stack
@ -28,11 +28,11 @@ ANWENDUNGSBESCHREIBUNG.txt
CLAUDE_PROJECT_README.md CLAUDE_PROJECT_README.md
debug-console.cmd debug-console.cmd
EXE-ERSTELLEN.md EXE-ERSTELLEN.md
gitea_push_debug.txt
install-dependencies.cmd install-dependencies.cmd
INSTALLATION.md INSTALLATION.md
LICENSE.txt LICENSE.txt
main.py main.py
README.md
admin-panel/ admin-panel/
│ ├── index.html │ ├── index.html
│ ├── package-lock.json │ ├── package-lock.json
@ -140,16 +140,22 @@ backend/
│ │ ├── migrate-users.js │ │ ├── migrate-users.js
│ │ ├── purge-users.js │ │ ├── purge-users.js
│ │ ├── reset-admin.js │ │ ├── reset-admin.js
│ │ ── seed-skills-from-frontend.js │ │ ── run-migrations.js
│ │ ├── seed-skills-from-frontend.js
│ │ └── migrations/
│ │ └── 0001_users_email_encrypt.js
│ ├── src/ │ ├── src/
│ │ ├── index.ts │ │ ├── index.ts
│ │ ├── config/ │ │ ├── config/
│ │ │ ├── appConfig.ts
│ │ │ ├── database.ts │ │ │ ├── database.ts
│ │ │ └── secureDatabase.ts │ │ │ └── secureDatabase.ts
│ │ ├── middleware/ │ │ ├── middleware/
│ │ │ ├── auth.ts │ │ │ ├── auth.ts
│ │ │ ├── errorHandler.ts │ │ │ ├── errorHandler.ts
│ │ │ └── roleAuth.ts │ │ │ └── roleAuth.ts
│ │ ├── repositories/
│ │ │ └── employeeRepository.ts
│ │ ├── routes/ │ │ ├── routes/
│ │ │ ├── analytics.ts │ │ │ ├── analytics.ts
│ │ │ ├── auth.ts │ │ │ ├── auth.ts
@ -162,18 +168,28 @@ backend/
│ │ │ ├── skills.ts │ │ │ ├── skills.ts
│ │ │ └── sync.ts │ │ │ └── sync.ts
│ │ ├── services/ │ │ ├── services/
│ │ │ ├── auditService.ts
│ │ │ ├── emailService.ts │ │ │ ├── emailService.ts
│ │ │ ├── encryption.ts │ │ │ ├── encryption.ts
│ │ │ ├── reminderService.ts │ │ │ ├── reminderService.ts
│ │ │ ├── syncScheduler.ts │ │ │ ├── syncScheduler.ts
│ │ │ └── syncService.ts │ │ │ └── syncService.ts
│ │ ── utils/ │ │ ── usecases/
│ │ ── logger.ts │ │ ── employees.ts
│ │ │ └── users.ts
│ │ ├── utils/
│ │ │ └── logger.ts
│ │ └── validation/
│ │ └── employeeValidators.ts
│ └── uploads/ │ └── uploads/
│ └── photos/ │ └── photos/
│ ├── 0def5f6f-c1ef-4f88-9105-600c75278f10.jpg │ ├── 0def5f6f-c1ef-4f88-9105-600c75278f10.jpg
│ ├── 72c09fa1-f0a8-444c-918f-95258ca56f61.gif │ ├── 72c09fa1-f0a8-444c-918f-95258ca56f61.gif
│ └── 80c44681-d6b4-474e-8ff1-c6d02da0cd7d.gif │ └── 80c44681-d6b4-474e-8ff1-c6d02da0cd7d.gif
docs/
│ ├── ARCHITECTURE.md
│ ├── REFAKTOR_PLAN.txt
│ └── SMOKE_TESTS.md
frontend/ frontend/
│ ├── electron-builder.json │ ├── electron-builder.json
│ ├── index-electron.html │ ├── index-electron.html
@ -205,6 +221,7 @@ frontend/
│ ├── main.tsx │ ├── main.tsx
│ ├── components/ │ ├── components/
│ │ ├── EmployeeCard.tsx │ │ ├── EmployeeCard.tsx
│ │ ├── ErrorBoundary.tsx
│ │ ├── Header.tsx │ │ ├── Header.tsx
│ │ ├── Layout.tsx │ │ ├── Layout.tsx
│ │ ├── PhotoPreview.tsx │ │ ├── PhotoPreview.tsx
@ -275,3 +292,5 @@ This project is managed with Claude Project Manager. To work with this project:
- README updated on 2025-08-01 23:08:41 - README updated on 2025-08-01 23:08:41
- README updated on 2025-08-01 23:08:52 - README updated on 2025-08-01 23:08:52
- README updated on 2025-09-20 21:30:35 - README updated on 2025-09-20 21:30:35
- README updated on 2025-09-21 16:48:11
- README updated on 2025-09-21 16:48:44

Datei anzeigen

@ -1,44 +1,135 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../services/api' import { api } from '../services/api'
type Skill = { id: string; name: string; description?: string | null } type Skill = {
type Subcategory = { id: string; name: string; skills: Skill[] } id: string
type Category = { id: string; name: string; subcategories: Subcategory[] } name: string
category?: string
description?: string | null
userCount?: number
tags?: string[]
levelDistribution?: {
beginner: number // Level 1-3
intermediate: number // Level 4-6
expert: number // Level 7-10
}
requires_certification?: boolean
certification_months?: number
}
type Category = {
id: string
name: string
icon?: string
color?: string
skillCount?: number
}
type ViewMode = 'grid' | 'category'
export default function SkillManagement() { export default function SkillManagement() {
const [hierarchy, setHierarchy] = useState<Category[]>([]) const [skills, setSkills] = useState<Skill[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set())
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set())
const [sortBy, setSortBy] = useState<'name' | 'users' | 'category'>('name')
const [employeeCounts, setEmployeeCounts] = useState<Record<string, number>>({})
const [levelDistributions, setLevelDistributions] = useState<Record<string, { beginner: number, intermediate: number, expert: number }>>({})
const [openMain, setOpenMain] = useState<Record<string, boolean>>({}) // Modal states
const [openSub, setOpenSub] = useState<Record<string, boolean>>({}) const [showSkillModal, setShowSkillModal] = useState(false)
const [editingSkill, setEditingSkill] = useState<Skill | null>(null)
const [showImportModal, setShowImportModal] = useState(false)
// Inline create/edit states // Form states for skill modal
const [addingCat, setAddingCat] = useState(false) const [skillForm, setSkillForm] = useState({
const [newCat, setNewCat] = useState({ name: '' }) name: '',
description: '',
category: '',
tags: [] as string[],
requires_certification: false,
certification_months: 0
})
const [addingSub, setAddingSub] = useState<Record<string, boolean>>({}) // Category icon and color mapping
const [newSub, setNewSub] = useState<Record<string, { name: string }>>({}) const categoryConfig: Record<string, { icon: string, color: string }> = {
'communication': { icon: '💬', color: 'bg-green-100 text-green-800' },
'technical': { icon: '💻', color: 'bg-blue-100 text-blue-800' },
'operational': { icon: '🎯', color: 'bg-orange-100 text-orange-800' },
'analytical': { icon: '📊', color: 'bg-purple-100 text-purple-800' },
'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800' },
}
const [addingSkill, setAddingSkill] = useState<Record<string, boolean>>({}) // key: catId.subId // Predefined tags
const [newSkill, setNewSkill] = useState<Record<string, { name: string; description?: string }>>({}) const availableTags = [
'Programmierung', 'Datenanalyse', 'Forensik', 'Machine Learning',
'Ermittlung', 'Kommunikation', 'Führung', 'Sicherheit',
'Waffen', 'Fahrzeuge', 'Medizin', 'Recht'
]
const [editingCat, setEditingCat] = useState<string | null>(null) useEffect(() => {
const [editCatName, setEditCatName] = useState<string>('') loadData()
}, [])
const [editingSub, setEditingSub] = useState<string | null>(null) // key: catId.subId async function loadData() {
const [editSubName, setEditSubName] = useState<string>('') // First fetch employee counts, then skills with those counts
const { counts, distributions } = await fetchEmployeeCountsAndDistributions()
await fetchSkills(counts, distributions)
}
const [editingSkill, setEditingSkill] = useState<string | null>(null) // skill id async function fetchSkills(counts?: Record<string, number>, distributions?: Record<string, { beginner: number, intermediate: number, expert: number }>) {
const [editSkillData, setEditSkillData] = useState<{ name: string; description?: string }>({ name: '' })
useEffect(() => { fetchHierarchy() }, [])
async function fetchHierarchy() {
try { try {
setLoading(true) setLoading(true)
const res = await api.get('/skills/hierarchy') const res = await api.get('/skills/hierarchy')
setHierarchy(res.data.data || []) const hierarchy = res.data.data || []
// Use passed data or state
const skillCounts = counts || employeeCounts
const skillDistributions = distributions || levelDistributions
// Extract categories and flatten skills
const extractedCategories: Category[] = []
const flatSkills: Skill[] = []
const seenCategories = new Set<string>()
hierarchy.forEach((cat: any) => {
// Add category if not seen
if (!seenCategories.has(cat.id)) {
seenCategories.add(cat.id)
const config = categoryConfig[cat.id] || { icon: '📁', color: 'bg-gray-100 text-gray-800' }
extractedCategories.push({
id: cat.id,
name: cat.name,
icon: config.icon,
color: config.color,
skillCount: 0
})
}
cat.subcategories?.forEach((sub: any) => {
sub.skills?.forEach((skill: any) => {
flatSkills.push({
...skill,
category: cat.id,
tags: [cat.name, sub.name],
userCount: skillCounts[skill.id] || 0,
levelDistribution: skillDistributions[skill.id] || { beginner: 0, intermediate: 0, expert: 0 }
})
})
})
})
// Update skill counts for categories
extractedCategories.forEach(cat => {
cat.skillCount = flatSkills.filter(s => s.category === cat.id).length
})
setCategories(extractedCategories)
setSkills(flatSkills)
} catch (e) { } catch (e) {
setError('Skills konnten nicht geladen werden') setError('Skills konnten nicht geladen werden')
} finally { } finally {
@ -46,237 +137,632 @@ export default function SkillManagement() {
} }
} }
async function fetchEmployeeCountsAndDistributions() {
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
function keyFor(catId: string, subId: string) { return `${catId}.${subId}` }
function toggleMain(catId: string) { setOpenMain(prev => ({ ...prev, [catId]: !prev[catId] })) }
function toggleSub(catId: string, subId: string) { const k = keyFor(catId, subId); setOpenSub(prev => ({ ...prev, [k]: !prev[k] })) }
// Category actions
async function createCategory() {
if (!newCat.name) return
try { try {
const id = slugify(newCat.name) // Fetch employees and count skills with level distribution
await api.post('/skills/categories', { id, name: newCat.name }) const res = await api.get('/employees')
setNewCat({ name: '' }) const employees = res.data.data || []
setAddingCat(false) const counts: Record<string, number> = {}
fetchHierarchy() const distributions: Record<string, { beginner: number, intermediate: number, expert: number }> = {}
} catch { setError('Kategorie konnte nicht erstellt werden') }
employees.forEach((emp: any) => {
emp.skills?.forEach((skill: any) => {
counts[skill.id] = (counts[skill.id] || 0) + 1
// Initialize distribution if not exists
if (!distributions[skill.id]) {
distributions[skill.id] = { beginner: 0, intermediate: 0, expert: 0 }
}
// Parse level and categorize
const level = parseInt(skill.level) || 0
if (level >= 1 && level <= 3) {
distributions[skill.id].beginner++
} else if (level >= 4 && level <= 6) {
distributions[skill.id].intermediate++
} else if (level >= 7 && level <= 10) {
distributions[skill.id].expert++
}
})
})
setEmployeeCounts(counts)
setLevelDistributions(distributions)
return { counts, distributions }
} catch (e) {
console.error('Could not fetch employee counts')
return { counts: {}, distributions: {} }
}
} }
function startEditCategory(cat: Category) { setEditingCat(cat.id); setEditCatName(cat.name) } const filteredAndSortedSkills = skills
async function saveCategory(catId: string) { .filter(skill => {
try { const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
await api.put(`/skills/categories/${catId}`, { name: editCatName }) skill.description?.toLowerCase().includes(searchQuery.toLowerCase())
setEditingCat(null) const matchesCategory = selectedCategories.size === 0 || selectedCategories.has(skill.category || '')
fetchHierarchy() return matchesSearch && matchesCategory
} catch { setError('Kategorie konnte nicht aktualisiert werden') } })
} .sort((a, b) => {
async function deleteCategory(catId: string) { switch (sortBy) {
if (!confirm('Kategorie und alle Inhalte löschen?')) return case 'name':
try { await api.delete(`/skills/categories/${catId}`); fetchHierarchy() } catch { setError('Kategorie konnte nicht gelöscht werden') } return a.name.localeCompare(b.name)
case 'users':
return (employeeCounts[b.id] || 0) - (employeeCounts[a.id] || 0)
case 'category':
const catA = categories.find(c => c.id === a.category)?.name || ''
const catB = categories.find(c => c.id === b.category)?.name || ''
return catA.localeCompare(catB)
default:
return 0
}
})
const quickStats = {
total: skills.length,
categories: categories.length,
newThisWeek: Math.floor(skills.length * 0.15)
} }
// Subcategory actions function openSkillModal(skill?: Skill) {
function startAddSub(catId: string) { setAddingSub(prev => ({ ...prev, [catId]: true })); setNewSub(prev => ({ ...prev, [catId]: { name: '' } })) } if (skill) {
async function createSub(catId: string) { setEditingSkill(skill)
const data = newSub[catId] setSkillForm({
if (!data || !data.name) return name: skill.name,
try { description: skill.description || '',
const id = slugify(data.name) category: skill.category || '',
await api.post(`/skills/categories/${catId}/subcategories`, { id, name: data.name }) tags: skill.tags || [],
setAddingSub(prev => ({ ...prev, [catId]: false })) requires_certification: skill.requires_certification || false,
fetchHierarchy() certification_months: skill.certification_months || 0
} catch { setError('Unterkategorie konnte nicht erstellt werden') } })
} } else {
function startEditSub(catId: string, sub: Subcategory) { setEditingSub(keyFor(catId, sub.id)); setEditSubName(sub.name) }
async function saveSub(catId: string, subId: string) {
try {
await api.put(`/skills/categories/${catId}/subcategories/${subId}`, { name: editSubName })
setEditingSub(null)
fetchHierarchy()
} catch { setError('Unterkategorie konnte nicht aktualisiert werden') }
}
async function deleteSub(catId: string, subId: string) {
if (!confirm('Unterkategorie und enthaltene Skills löschen?')) return
try { await api.delete(`/skills/categories/${catId}/subcategories/${subId}`); fetchHierarchy() } catch { setError('Unterkategorie konnte nicht gelöscht werden') }
}
// Skill actions
function startAddSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
setAddingSkill(prev => ({ ...prev, [k]: true }))
setNewSkill(prev => ({ ...prev, [k]: { name: '', description: '' } }))
}
async function createSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
const data = newSkill[k]
if (!data || !data.name) return
try {
const id = slugify(data.name)
await api.post('/skills', { id, name: data.name, category: `${catId}.${subId}`, description: data.description || null })
setAddingSkill(prev => ({ ...prev, [k]: false }))
fetchHierarchy()
} catch { setError('Skill konnte nicht erstellt werden') }
}
function startEditSkill(skill: Skill) { setEditingSkill(skill.id); setEditSkillData({ name: skill.name, description: skill.description || '' }) }
async function saveSkill(id: string) {
try {
await api.put(`/skills/${id}`, { name: editSkillData.name, description: editSkillData.description ?? null })
setEditingSkill(null) setEditingSkill(null)
fetchHierarchy() setSkillForm({
} catch { setError('Skill konnte nicht aktualisiert werden') } name: '',
description: '',
category: '',
tags: [],
requires_certification: false,
certification_months: 0
})
}
setShowSkillModal(true)
} }
async function saveSkill() {
try {
if (editingSkill) {
await api.put(`/skills/${editingSkill.id}`, {
name: skillForm.name,
description: skillForm.description,
category: skillForm.category
})
} else {
await api.post('/skills', {
name: skillForm.name,
description: skillForm.description,
category: skillForm.category || 'technical.general'
})
}
setShowSkillModal(false)
loadData()
} catch (e) {
setError('Skill konnte nicht gespeichert werden')
}
}
async function deleteSkill(id: string) { async function deleteSkill(id: string) {
if (!confirm('Skill löschen?')) return if (!confirm('Skill wirklich löschen?')) return
try { await api.delete(`/skills/${id}`); fetchHierarchy() } catch { setError('Skill konnte nicht gelöscht werden') } try {
await api.delete(`/skills/${id}`)
loadData()
} catch (e) {
setError('Skill konnte nicht gelöscht werden')
}
}
function toggleSkillSelection(skillId: string) {
const newSelection = new Set(selectedSkills)
if (newSelection.has(skillId)) {
newSelection.delete(skillId)
} else {
newSelection.add(skillId)
}
setSelectedSkills(newSelection)
}
async function bulkDelete() {
if (!confirm(`${selectedSkills.size} Skills wirklich löschen?`)) return
try {
for (const skillId of selectedSkills) {
await api.delete(`/skills/${skillId}`)
}
setSelectedSkills(new Set())
loadData()
} catch (e) {
setError('Skills konnten nicht gelöscht werden')
}
}
function exportSkills() {
const data = filteredAndSortedSkills.map(s => ({
Name: s.name,
Beschreibung: s.description,
Kategorie: s.category,
Tags: s.tags?.join(', ')
}))
const csv = [
Object.keys(data[0]).join(','),
...data.map(row => Object.values(row).map(v => `"${v || ''}"`).join(','))
].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'skills-export.csv'
a.click()
}
function SkillCard({ skill }: { skill: Skill }) {
const category = categories.find(c => c.id === skill.category)
const dist = skill.levelDistribution || { beginner: 0, intermediate: 0, expert: 0 }
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow border border-border-default">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-primary">{skill.name}</h3>
<input
type="checkbox"
checked={selectedSkills.has(skill.id)}
onChange={() => toggleSkillSelection(skill.id)}
className="w-5 h-5 rounded border-gray-300"
/>
</div>
<div className="flex gap-2 mb-3">
{dist.beginner > 0 && (
<div className="relative w-8 h-8 bg-red-500 rounded-full flex items-center justify-center" title={`${dist.beginner} Anfänger`}>
<span className="text-white text-xs font-bold">{dist.beginner}</span>
</div>
)}
{dist.intermediate > 0 && (
<div className="relative w-8 h-8 bg-green-500 flex items-center justify-center" title={`${dist.intermediate} Fortgeschrittene`}>
<span className="text-white text-xs font-bold">{dist.intermediate}</span>
</div>
)}
{dist.expert > 0 && (
<div className="relative flex items-center justify-center" title={`${dist.expert} Experten`}>
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
fill="rgb(147 51 234)"
/>
</svg>
<span className="absolute text-white text-xs font-bold">{dist.expert}</span>
</div>
)}
{(dist.beginner + dist.intermediate + dist.expert) === 0 && (
<span className="text-gray-400 text-sm">Keine Zuordnungen</span>
)}
</div>
{skill.description && (
<p className="text-sm text-secondary mb-3 line-clamp-2">{skill.description}</p>
)}
<div className="flex items-start justify-between gap-2 text-sm">
<span className={`inline-flex items-start px-2 py-1 rounded-full text-xs font-medium ${category?.color || 'bg-gray-100'}`}>
<span className="flex-shrink-0">{category?.icon}</span>
<span className="ml-1">{category?.name}</span>
</span>
<span className="text-tertiary flex-shrink-0">👥 {employeeCounts[skill.id] || skill.userCount || 0}</span>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => openSkillModal(skill)}
className="text-xs text-blue-600 hover:text-blue-800"
>
Bearbeiten
</button>
<button
onClick={() => deleteSkill(skill.id)}
className="text-xs text-red-600 hover:text-red-800"
>
Löschen
</button>
</div>
</div>
)
}
function CategoryView() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map(category => {
const categorySkills = filteredAndSortedSkills.filter(s => s.category === category.id)
return (
<div key={category.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="text-2xl">{category.icon}</span>
{category.name}
</h3>
<span className="text-sm text-gray-500">{categorySkills.length} Skills</span>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{categorySkills.slice(0, 10).map(skill => (
<div
key={skill.id}
className="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer"
onClick={() => openSkillModal(skill)}
>
<span className="text-sm">{skill.name}</span>
<span className="text-xs text-gray-400">👥 {employeeCounts[skill.id] || skill.userCount || 0}</span>
</div>
))}
{categorySkills.length > 10 && (
<div className="text-sm text-center text-gray-500 pt-2">
+{categorySkills.length - 10} weitere
</div>
)}
</div>
<button
onClick={() => {
setSkillForm(prev => ({ ...prev, category: category.id }))
openSkillModal()
}}
className="w-full mt-4 text-sm text-blue-600 hover:text-blue-800"
>
+ Skill hinzufügen
</button>
</div>
)
})}
</div>
)
} }
return ( return (
<div> <div>
{/* Header */}
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div> <div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">Skill-Verwaltung</h1> <h1 className="text-3xl font-bold text-primary mb-2">Skills Management</h1>
<p className="text-body text-secondary">Kategorien und Unterkategorien wie im Frontend (ohne Niveaus)</p>
</div> </div>
<div> <div className="flex gap-2">
{!addingCat ? ( <button
<button className="btn-primary" onClick={() => setAddingCat(true)}>+ Kategorie</button> onClick={() => setShowImportModal(true)}
) : ( className="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
<div className="flex items-center gap-2"> >
<input className="input-field" placeholder="Name der Kategorie (z. B. Technische Fähigkeiten)" value={newCat.name} onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} /> Import
<button className="btn-primary" onClick={createCategory}>Speichern</button> </button>
<button className="btn-secondary" onClick={() => { setAddingCat(false); setNewCat({ name: '' }) }}>Abbrechen</button> <button
</div> onClick={exportSkills}
)} className="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Export
</button>
</div> </div>
</div> </div>
{/* Search and Stats */}
<div className="mb-6">
<div className="flex gap-4 items-center mb-4">
<div className="flex-1">
<input
type="text"
placeholder="🔍 Skills suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
/>
</div>
<button
onClick={() => openSkillModal()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+ Neuer Skill
</button>
</div>
{/* Quick Stats */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-gray-100">{quickStats.total}</span> Skills
<span className="font-semibold text-gray-900 dark:text-gray-100 ml-2">{quickStats.categories}</span> Kategorien
<span className="font-semibold text-gray-900 dark:text-gray-100 ml-2">{quickStats.newThisWeek}</span> neue diese Woche
</div>
</div>
</div>
{error && <div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>} {/* Filters and View Toggle */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'name' | 'users' | 'category')}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800"
>
<option value="name">Nach Name</option>
<option value="users">Nach Nutzerzahl</option>
<option value="category">Nach Kategorie</option>
</select>
{loading ? ( {selectedSkills.size > 0 && (
<div className="text-secondary">Lade Skills...</div> <button
) : ( onClick={bulkDelete}
hierarchy.map((cat) => ( className="px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
<div key={cat.id} className="card mb-4"> >
<div className="flex items-center justify-between"> {selectedSkills.size} ausgewählte löschen
<div className="flex items-center gap-3"> </button>
<button className="btn-secondary h-8 px-3" onClick={() => toggleMain(cat.id)}>{openMain[cat.id] ? '' : '+'}</button>
{editingCat === cat.id ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveCategory(cat.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingCat(null)}></button>
</div>
) : (
<h3 className="text-title-card font-semibold text-primary">{cat.name}</h3>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditCategory(cat)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSub(cat.id)}>+ Unterkategorie</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteCategory(cat.id)}>Löschen</button>
</div>
</div>
{addingSub[cat.id] && (
<div className="mt-3 flex items-center gap-2">
<input className="input-field" placeholder="Name der Unterkategorie (z. B. Programmierung)" value={newSub[cat.id]?.name || ''} onChange={(e) => setNewSub(prev => ({ ...prev, [cat.id]: { ...(prev[cat.id] || { name: '' }), name: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSub(cat.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSub(prev => ({ ...prev, [cat.id]: false }))}>Abbrechen</button>
</div>
)}
{openMain[cat.id] && (
<div className="mt-3">
{cat.subcategories.map((sub) => (
<div key={`${cat.id}.${sub.id}`} className="mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="btn-secondary h-8 px-3" onClick={() => toggleSub(cat.id, sub.id)}>{openSub[keyFor(cat.id, sub.id)] ? '' : '+'}</button>
{editingSub === keyFor(cat.id, sub.id) ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editSubName} onChange={(e) => setEditSubName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveSub(cat.id, sub.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSub(null)}></button>
</div>
) : (
<div className="font-medium text-secondary">{sub.name}</div>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditSub(cat.id, sub)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSkill(cat.id, sub.id)}>+ Skill</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSub(cat.id, sub.id)}>Löschen</button>
</div>
</div>
{addingSkill[keyFor(cat.id, sub.id)] && (
<div className="mt-2 flex items-center gap-2">
<input className="input-field" placeholder="Skill-Name (z. B. Python)" value={newSkill[keyFor(cat.id, sub.id)]?.name || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), name: e.target.value } }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={newSkill[keyFor(cat.id, sub.id)]?.description || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), description: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSkill(cat.id, sub.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: false }))}>Abbrechen</button>
</div>
)}
{openSub[keyFor(cat.id, sub.id)] && (
<div className="divide-y divide-border-default mt-2">
{sub.skills.map((sk) => (
<div key={sk.id} className="py-2 flex items-center justify-between">
{editingSkill === sk.id ? (
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
<input className="input-field" value={editSkillData.name} onChange={(e) => setEditSkillData(prev => ({ ...prev, name: e.target.value }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={editSkillData.description || ''} onChange={(e) => setEditSkillData(prev => ({ ...prev, description: e.target.value }))} />
</div>
) : (
<div className="flex-1 pr-4">
<div className="font-medium text-primary">{sk.name}</div>
{sk.description && <div className="text-small text-tertiary">{sk.description}</div>}
</div>
)}
<div className="flex items-center gap-2">
{editingSkill === sk.id ? (
<>
<button className="btn-primary h-8 px-3" onClick={() => saveSkill(sk.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSkill(null)}></button>
</>
) : (
<>
<input type="checkbox" className="w-4 h-4 mr-2" checked readOnly aria-label="Aktiv" />
<button className="btn-secondary h-8 px-3" onClick={() => startEditSkill(sk)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSkill(sk.id)}>Löschen</button>
</>
)}
</div>
</div>
))}
{sub.skills.length === 0 && (
<div className="py-2 text-small text-tertiary">Keine Skills in dieser Unterkategorie</div>
)}
</div>
)}
</div>
))}
{cat.subcategories.length === 0 && (
<div className="text-small text-tertiary mt-2">Keine Unterkategorien</div>
)}
</div>
)} )}
</div> </div>
))
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`px-3 py-1 rounded ${viewMode === 'grid' ? 'bg-white dark:bg-gray-600 shadow' : ''}`}
>
Grid View
</button>
<button
onClick={() => setViewMode('category')}
className={`px-3 py-1 rounded ${viewMode === 'category' ? 'bg-white dark:bg-gray-600 shadow' : ''}`}
>
Category View
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => {
const newSelection = new Set(selectedCategories)
if (newSelection.has(cat.id)) {
newSelection.delete(cat.id)
} else {
newSelection.add(cat.id)
}
setSelectedCategories(newSelection)
}}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
selectedCategories.has(cat.id)
? cat.color
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
>
<span>{cat.icon}</span>
<span>{cat.name}</span>
{selectedCategories.has(cat.id) && <span className="ml-1"></span>}
</button>
))}
{selectedCategories.size > 0 && (
<button
onClick={() => setSelectedCategories(new Set())}
className="px-3 py-1.5 rounded-full text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500"
>
Alle anzeigen
</button>
)}
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Main Content */}
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<p className="mt-2 text-gray-600">Lade Skills...</p>
</div>
) : (
<>
{viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredAndSortedSkills.map(skill => (
<SkillCard key={skill.id} skill={skill} />
))}
</div>
)}
{viewMode === 'category' && <CategoryView />}
</>
)}
{/* Skill Modal */}
{showSkillModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">
{editingSkill ? 'Skill bearbeiten' : 'Neuer Skill'}
</h2>
<button
onClick={() => setShowSkillModal(false)}
className="text-gray-500 hover:text-gray-700 text-2xl"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Name *</label>
<input
type="text"
value={skillForm.name}
onChange={(e) => setSkillForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
placeholder="z.B. Python"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Beschreibung</label>
<textarea
value={skillForm.description}
onChange={(e) => setSkillForm(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
rows={3}
placeholder="Beschreibung des Skills..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Hauptkategorie</label>
<select
value={skillForm.category}
onChange={(e) => setSkillForm(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Kategorie wählen...</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Tags (Mehrfachzuordnung)</label>
<div className="border border-gray-300 rounded-lg p-3 dark:border-gray-600">
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
onClick={() => {
setSkillForm(prev => ({
...prev,
tags: prev.tags.includes(tag)
? prev.tags.filter(t => t !== tag)
: [...prev.tags, tag]
}))
}}
className={`px-3 py-1 rounded-full text-sm ${
skillForm.tags.includes(tag)
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{tag} {skillForm.tags.includes(tag) && '✓'}
</button>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Skill-Level Definition (1-10 Skala)</label>
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 text-sm space-y-1">
<div className="flex items-center gap-2">
<span className="text-red-600">1-3</span> Anfänger (Grundkenntnisse)
</div>
<div className="flex items-center gap-2">
<span className="text-green-600">4-6</span> Fortgeschritten (Solide Anwendungskenntnisse)
</div>
<div className="flex items-center gap-2">
<span className="text-purple-600">7-10</span> Experte (Führungsexperte, Spezialist)
</div>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center">
<input
type="checkbox"
checked={skillForm.requires_certification}
onChange={(e) => setSkillForm(prev => ({ ...prev, requires_certification: e.target.checked }))}
className="mr-2"
/>
Zertifizierung erforderlich
</label>
{skillForm.requires_certification && (
<div className="flex items-center gap-2">
<label>Ablauf nach:</label>
<input
type="number"
value={skillForm.certification_months}
onChange={(e) => setSkillForm(prev => ({ ...prev, certification_months: parseInt(e.target.value) || 0 }))}
className="w-20 px-2 py-1 border border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<span>Monaten</span>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowSkillModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onClick={saveSkill}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Speichern
</button>
</div>
</div>
</div>
</div>
)}
{/* Import Modal */}
{showImportModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full p-6">
<h2 className="text-xl font-bold mb-4">Skills importieren</h2>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
CSV-Datei hier ablegen oder
</p>
<input
type="file"
accept=".csv"
className="hidden"
id="file-upload"
onChange={(e) => {
// Handle file upload
console.log('File upload:', e.target.files?.[0])
setShowImportModal(false)
}}
/>
<label
htmlFor="file-upload"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer"
>
Datei auswählen
</label>
</div>
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Format: Name, Beschreibung, Kategorie, Tags (kommagetrennt)
</div>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={() => setShowImportModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
</div>
</div>
</div>
)} )}
</div> </div>
) )

1286
backend/package-lock.json generiert

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

Datei anzeigen

@ -0,0 +1,43 @@
const CryptoJS = require('crypto-js')
const crypto = require('crypto')
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!'
function encrypt(text) {
if (!text) return null
try {
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString()
} catch (e) {
return text
}
}
function hash(text) {
if (!text) return null
return crypto.createHash('sha256').update(String(text).toLowerCase()).digest('hex')
}
module.exports.up = function up(db) {
// Ensure users table has email_hash column
try {
db.exec('ALTER TABLE users ADD COLUMN email_hash TEXT')
} catch {}
// Populate encryption/hash where missing
const users = db.prepare('SELECT id, email FROM users').all()
const update = db.prepare('UPDATE users SET email = ?, email_hash = ? WHERE id = ?')
const tx = db.transaction(() => {
for (const u of users) {
const hasEncryptedMarker = typeof u.email === 'string' && u.email.includes('U2FsdGVkX1')
const plainEmail = u.email
const encrypted = hasEncryptedMarker ? u.email : encrypt(plainEmail)
const hashed = hash(plainEmail)
update.run(encrypted, hashed, u.id)
}
})
tx()
// Add unique constraint index for email_hash if not exists
try {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_hash_unique ON users(email_hash)')
} catch {}
}

Datei anzeigen

@ -0,0 +1,54 @@
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath)
function ensureSchemaTable() {
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`)
}
function getApplied() {
try {
const rows = db.prepare('SELECT id FROM schema_version').all()
return new Set(rows.map(r => r.id))
} catch {
return new Set()
}
}
function applyMigration(file) {
const migration = require(file)
const id = path.basename(file)
const tx = db.transaction(() => {
migration.up(db)
db.prepare('INSERT INTO schema_version (id, applied_at) VALUES (?, ?)').run(id, new Date().toISOString())
})
tx()
console.log('Applied migration:', id)
}
function main() {
ensureSchemaTable()
const applied = getApplied()
const dir = path.join(__dirname, 'migrations')
if (!fs.existsSync(dir)) {
console.log('No migrations directory found, skipping.')
process.exit(0)
}
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.js'))
.sort()
.map(f => path.join(dir, f))
for (const file of files) {
const id = path.basename(file)
if (!applied.has(id)) {
applyMigration(file)
}
}
console.log('Migrations complete.')
}
main()

Datei anzeigen

@ -0,0 +1,43 @@
import dotenv from 'dotenv'
// Load environment variables early
dotenv.config()
export interface AppConfig {
nodeEnv: string
port: number
jwtSecret: string | null
email: {
host?: string
port?: number
user?: string
pass?: string
secure?: boolean
from?: string
}
}
export function getConfig(): AppConfig {
return {
nodeEnv: process.env.NODE_ENV || 'development',
port: Number(process.env.PORT || 3004),
jwtSecret: process.env.JWT_SECRET || null,
email: {
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT ? Number(process.env.EMAIL_PORT) : undefined,
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
secure: process.env.EMAIL_SECURE === 'true',
from: process.env.EMAIL_FROM,
},
}
}
export function assertProdSecretsSet(cfg: AppConfig) {
if (cfg.nodeEnv === 'production') {
if (!cfg.jwtSecret) {
throw new Error('JWT_SECRET must be set in production')
}
}
}

Datei anzeigen

@ -0,0 +1,317 @@
import { db, encryptedDb } from '../config/secureDatabase'
import { Employee } from '@skillmate/shared'
import { v4 as uuidv4 } from 'uuid'
export interface EmployeeInput {
firstName: string
lastName: string
employeeNumber?: string | null
photo?: string | null
position?: string
department: string
email: string
phone?: string | null
mobile?: string | null
office?: string | null
availability?: string
clearance?: { level?: string; validUntil?: string | Date | null; issuedDate?: string | Date | null } | null
skills?: Array<{ id: string; level?: any; verified?: boolean; verifiedBy?: string | null; verifiedDate?: string | Date | null }>
languages?: Array<{ code: string; level: string }>
specializations?: string[]
}
export function getAllWithDetails(): Employee[] {
const emps = (encryptedDb.getAllEmployees() as any[]) || []
if (emps.length === 0) return []
const ids = emps.map((e: any) => e.id)
const placeholders = ids.map(() => '?').join(',')
// Batch fetch related data
const skillsRows = db.prepare(`
SELECT es.employee_id, s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id IN (${placeholders})
`).all(...ids) as any[]
const langRows = db.prepare(`
SELECT employee_id, language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id IN (${placeholders})
`).all(...ids) as any[]
const specRows = db.prepare(`
SELECT employee_id, name FROM specializations WHERE employee_id IN (${placeholders})
`).all(...ids) as any[]
const skillsByEmp = new Map<string, any[]>()
for (const r of skillsRows) {
if (!skillsByEmp.has(r.employee_id)) skillsByEmp.set(r.employee_id, [])
skillsByEmp.get(r.employee_id)!.push(r)
}
const langsByEmp = new Map<string, any[]>()
for (const r of langRows) {
if (!langsByEmp.has(r.employee_id)) langsByEmp.set(r.employee_id, [])
langsByEmp.get(r.employee_id)!.push(r)
}
const specsByEmp = new Map<string, string[]>()
for (const r of specRows) {
if (!specsByEmp.has(r.employee_id)) specsByEmp.set(r.employee_id, [])
specsByEmp.get(r.employee_id)!.push(r.name)
}
const mapProf = (p: string): 'basic' | 'fluent' | 'native' | 'business' => {
const m: Record<string, any> = { A1: 'basic', A2: 'basic', B1: 'business', B2: 'business', C1: 'fluent', C2: 'fluent', Muttersprache: 'native', native: 'native', fluent: 'fluent', advanced: 'business', intermediate: 'business', basic: 'basic' }
return m[p] || 'basic'
}
return emps.map((emp: any) => {
const s = skillsByEmp.get(emp.id) || []
const l = langsByEmp.get(emp.id) || []
const sp = specsByEmp.get(emp.id) || []
return {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: s.map((r: any) => ({ id: r.id, name: r.name, category: r.category, level: r.level, verified: Boolean(r.verified), verifiedBy: r.verified_by, verifiedDate: r.verified_date ? new Date(r.verified_date) : undefined })),
languages: l.map((r: any) => ({ code: r.language, level: mapProf(r.proficiency) })),
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? { level: emp.clearance_level, validUntil: new Date(emp.clearance_valid_until), issuedDate: new Date(emp.clearance_issued_date) } : undefined,
specializations: sp,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
} as Employee
})
}
export function getByIdWithDetails(id: string): Employee | null {
const emp = encryptedDb.getEmployee(id) as any
if (!emp) return null
return buildEmployeeWithDetails(emp)
}
function buildEmployeeWithDetails(emp: any): Employee {
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const mapProf = (p: string): 'basic' | 'fluent' | 'native' | 'business' => {
const m: Record<string, any> = { A1: 'basic', A2: 'basic', B1: 'business', B2: 'business', C1: 'fluent', C2: 'fluent', Muttersprache: 'native', native: 'native', fluent: 'fluent', advanced: 'business', intermediate: 'business', basic: 'basic' }
return m[p] || 'basic'
}
return {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language,
level: mapProf(l.proficiency)
})),
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
}
export function createEmployee(input: EmployeeInput, actorUserId: string): { id: string } {
const now = new Date().toISOString()
const employeeId = uuidv4()
const position = input.position || 'Mitarbeiter'
const phone = input.phone || 'Nicht angegeben'
const availability = input.availability || 'available'
const employeeNumber = input.employeeNumber || `EMP${Date.now()}`
const tx = db.transaction(() => {
// Uniqueness check for employee number
const existingEmployee = db.prepare('SELECT id FROM employees WHERE employee_number = ?').get(employeeNumber)
if (existingEmployee) {
const err: any = new Error('Employee number already exists')
err.statusCode = 409
throw err
}
encryptedDb.insertEmployee({
id: employeeId,
first_name: input.firstName,
last_name: input.lastName,
employee_number: employeeNumber,
photo: input.photo || null,
position,
department: input.department,
email: input.email,
phone,
mobile: input.mobile || null,
office: input.office || null,
availability,
clearance_level: input.clearance?.level || null,
clearance_valid_until: input.clearance?.validUntil || null,
clearance_issued_date: input.clearance?.issuedDate || null,
created_at: now,
updated_at: now,
created_by: actorUserId,
updated_by: actorUserId,
})
if (input.skills && input.skills.length > 0) {
const stmt = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
for (const s of input.skills) {
const exists = checkSkillExists.get(s.id)
if (!exists) continue
stmt.run(employeeId, s.id, typeof s.level === 'number' ? s.level : (parseInt(String(s.level)) || null), s.verified ? 1 : 0, s.verifiedBy || null, s.verifiedDate || null)
}
}
if (input.languages && input.languages.length > 0) {
const stmt = db.prepare(`
INSERT INTO language_skills (id, employee_id, language, proficiency, certified, certificate_type, is_native, can_interpret)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const l of input.languages) {
stmt.run(uuidv4(), employeeId, l.code, l.level, 0, null, 0, 0)
}
}
if (input.specializations && input.specializations.length > 0) {
const stmt = db.prepare('INSERT INTO specializations (id, employee_id, name) VALUES (?, ?, ?)')
for (const name of input.specializations) {
stmt.run(uuidv4(), employeeId, name)
}
}
})
tx()
return { id: employeeId }
}
export function updateEmployee(id: string, input: EmployeeInput, actorUserId: string) {
const now = new Date().toISOString()
const tx = db.transaction(() => {
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
const err: any = new Error('Employee not found')
err.statusCode = 404
throw err
}
db.prepare(`
UPDATE employees SET
first_name = ?, last_name = ?, position = ?, department = ?,
email = ?, email_hash = ?, phone = ?, phone_hash = ?,
mobile = ?, office = ?, availability = ?,
clearance_level = ?, clearance_valid_until = ?, clearance_issued_date = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
input.firstName, input.lastName, input.position || 'Mitarbeiter', input.department,
// encryption handled in secure db layer caller; here store encrypted values already in input? Route prepares with FieldEncryption
input.email, // already encrypted by route layer
null, // email_hash set by route if needed
input.phone || '',
null, // phone_hash set by route if needed
input.mobile || null,
input.office || null,
input.availability || 'available',
input.clearance?.level || null,
input.clearance?.validUntil || null,
input.clearance?.issuedDate || null,
now,
actorUserId,
id
)
if (input.skills) {
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
if (input.skills.length > 0) {
const insert = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
for (const s of input.skills) {
const exists = checkSkillExists.get(s.id)
if (!exists) continue
insert.run(
id,
s.id,
typeof s.level === 'number' ? s.level : (parseInt(String(s.level)) || null),
s.verified ? 1 : 0,
s.verifiedBy || null,
s.verifiedDate || null
)
}
}
}
})
tx()
}
export function deleteEmployee(id: string) {
const tx = db.transaction(() => {
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
const err: any = new Error('Employee not found')
err.statusCode = 404
throw err
}
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM employees WHERE id = ?').run(id)
})
tx()
}

Datei anzeigen

@ -0,0 +1,35 @@
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/secureDatabase'
import { logger } from '../utils/logger'
import type { Request } from 'express'
export function logSecurityAudit(
action: 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'failed_login',
entityType: string,
entityId: string,
userId: string,
req: Request,
riskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low'
) {
try {
db.prepare(`
INSERT INTO security_audit_log (
id, entity_type, entity_id, action, user_id,
timestamp, ip_address, user_agent, risk_level
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
entityType,
entityId,
action,
userId,
new Date().toISOString(),
(req as any).ip || (req as any).connection?.remoteAddress,
req.get('user-agent'),
riskLevel
)
} catch (error) {
logger.error('Failed to log security audit:', error)
}
}

Datei anzeigen

@ -0,0 +1,123 @@
import { db } from '../../config/database'
export async function applyChanges(payload: any) {
const { type, action, data } = payload
switch (type) {
case 'employees':
await syncEmployee(action, data)
break
case 'skills':
await syncSkill(action, data)
break
case 'users':
await syncUser(action, data)
break
case 'settings':
await syncSettings(action, data)
break
}
}
async function syncEmployee(action: string, data: any) {
switch (action) {
case 'create':
db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.id, data.firstName, data.lastName, data.employeeNumber,
data.photo, data.position, data.department, data.email,
data.phone, data.mobile, data.office, data.availability,
data.clearance?.level, data.clearance?.validUntil,
data.clearance?.issuedDate, data.createdAt, data.updatedAt,
data.createdBy
)
if (data.skills) {
for (const skill of data.skills) {
db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
data.id, skill.id, skill.level,
skill.verified ? 1 : 0, skill.verifiedBy, skill.verifiedDate
)
}
}
break
case 'update':
db.prepare(`
UPDATE employees SET
first_name = ?, last_name = ?, position = ?, department = ?,
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
data.firstName, data.lastName, data.position, data.department,
data.email, data.phone, data.mobile, data.office, data.availability,
data.updatedAt, data.updatedBy, data.id
)
break
case 'delete':
db.prepare('DELETE FROM employees WHERE id = ?').run(data.id)
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(data.id)
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(data.id)
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(data.id)
break
}
}
async function syncSkill(action: string, data: any) {
switch (action) {
case 'create':
db.prepare(`
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`).run(data.id, data.name, data.category, data.description || null, data.requiresCertification ? 1 : 0, data.expiresAfter || null)
break
case 'update':
db.prepare(`
UPDATE skills SET name = COALESCE(?, name), category = COALESCE(?, category), description = COALESCE(?, description)
WHERE id = ?
`).run(data.name || null, data.category || null, data.description || null, data.id)
break
case 'delete':
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(data.id)
db.prepare('DELETE FROM skills WHERE id = ?').run(data.id)
break
}
}
async function syncUser(action: string, data: any) {
switch (action) {
case 'create':
db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(data.id, data.username, data.email, data.password, data.role, data.employeeId || null, 1, data.createdAt, data.updatedAt)
break
case 'update':
db.prepare(`
UPDATE users SET username = ?, email = ?, role = ?, employee_id = ?, updated_at = ?
WHERE id = ?
`).run(data.username, data.email, data.role, data.employeeId || null, data.updatedAt, data.id)
break
case 'delete':
db.prepare('DELETE FROM users WHERE id = ?').run(data.id)
break
}
}
async function syncSettings(action: string, data: any) {
if (action === 'update') {
for (const [key, value] of Object.entries(data || {})) {
db.prepare(`INSERT INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
.run(key, String(value), new Date().toISOString())
}
}
}

Datei anzeigen

@ -0,0 +1,60 @@
import { db } from '../../config/database'
export const queueStore = {
getPending() {
return db.prepare(`
SELECT * FROM sync_log
WHERE status = 'pending'
ORDER BY created_at ASC
`).all() as any[]
},
markCompleted(id: string) {
db.prepare(`UPDATE sync_log SET status = 'completed', completed_at = ? WHERE id = ?`).run(new Date().toISOString(), id)
},
markFailed(id: string, message: string) {
db.prepare(`UPDATE sync_log SET status = 'failed', error_message = ? WHERE id = ?`).run(message, id)
},
updateMetadata(nodeId: string, result: { success: boolean; syncedItems: number; conflicts: any[]; errors: any[] }) {
const existing = db.prepare('SELECT * FROM sync_metadata WHERE node_id = ?').get(nodeId) as any
if (existing) {
db.prepare(`
UPDATE sync_metadata SET
last_sync_at = ?,
last_successful_sync = ?,
total_synced_items = total_synced_items + ?,
total_conflicts = total_conflicts + ?,
total_errors = total_errors + ?
WHERE node_id = ?
`).run(
new Date().toISOString(),
result.success ? new Date().toISOString() : existing.last_successful_sync,
result.syncedItems,
result.conflicts.length,
result.errors.length,
nodeId
)
} else {
db.prepare(`
INSERT INTO sync_metadata (
node_id, last_sync_at, last_successful_sync,
total_synced_items, total_conflicts, total_errors
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
nodeId,
new Date().toISOString(),
result.success ? new Date().toISOString() : null,
result.syncedItems,
result.conflicts.length,
result.errors.length
)
}
},
getSyncSettings() {
const settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
return settings || { autoSyncInterval: 'disabled', conflictResolution: 'admin' }
},
getNodeInfo(nodeId: string) {
return db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(nodeId)
}
}

Datei anzeigen

@ -0,0 +1,17 @@
import axios from 'axios'
export async function sendToNode(targetNode: any, payload: any, nodeId: string) {
const response = await axios.post(
`http://${targetNode.ip_address}:${targetNode.port}/api/sync/receive`,
payload,
{
headers: {
'Authorization': `Bearer ${targetNode.api_key}`,
'X-Node-Id': nodeId
},
timeout: 30000
}
)
return response.data
}

Datei anzeigen

@ -0,0 +1,69 @@
import { db } from '../../src/config/secureDatabase'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { FieldEncryption } from '../../src/services/encryption'
import { User, LoginResponse } from '@skillmate/shared'
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
export async function loginUser(identifier: string, password: string): Promise<LoginResponse> {
let userRow: any
if (identifier.includes('@')) {
const emailHash = FieldEncryption.hash(identifier)
userRow = db.prepare(`
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE email_hash = ? AND is_active = 1
`).get(emailHash)
} else {
userRow = db.prepare(`
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE username = ? AND is_active = 1
`).get(identifier)
}
if (!userRow) {
const e: any = new Error('Invalid credentials')
e.statusCode = 401
throw e
}
const isValidPassword = await bcrypt.compare(password, userRow.password)
if (!isValidPassword) {
const e: any = new Error('Invalid credentials')
e.statusCode = 401
throw e
}
const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
const user: User = {
id: userRow.id,
username: userRow.username,
email: FieldEncryption.decrypt(userRow.email) || '',
role: userRow.role,
employeeId: userRow.employee_id,
lastLogin: new Date(now),
isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at),
updatedAt: new Date(userRow.updated_at)
}
const token = jwt.sign(
{ user },
JWT_SECRET,
{ expiresIn: '24h' }
)
return {
user,
token: {
accessToken: token,
expiresIn: 86400,
tokenType: 'Bearer'
}
}
}

Datei anzeigen

@ -0,0 +1,109 @@
import { createEmployee as repoCreate, updateEmployee as repoUpdate, deleteEmployee as repoDelete, getAllWithDetails, getByIdWithDetails } from '../repositories/employeeRepository'
import { syncService } from '../services/syncService'
import { logSecurityAudit } from '../services/auditService'
import type { Request } from 'express'
export function listEmployeesUC() {
return getAllWithDetails()
}
export function getEmployeeUC(id: string) {
return getByIdWithDetails(id)
}
export async function createEmployeeUC(req: Request, body: any, actorUserId: string) {
const result = repoCreate({
firstName: body.firstName,
lastName: body.lastName,
employeeNumber: body.employeeNumber,
photo: body.photo,
position: body.position,
department: body.department,
email: body.email,
phone: body.phone,
mobile: body.mobile,
office: body.office,
availability: body.availability,
clearance: body.clearance,
skills: body.skills || [],
languages: body.languages || [],
specializations: body.specializations || [],
}, actorUserId)
logSecurityAudit('create', 'employees', result.id, actorUserId, req, 'medium')
const now = new Date().toISOString()
const newEmployee = {
id: result.id,
firstName: body.firstName,
lastName: body.lastName,
employeeNumber: body.employeeNumber || null,
photo: body.photo || null,
position: body.position || 'Mitarbeiter',
department: body.department,
email: body.email,
phone: body.phone || 'Nicht angegeben',
mobile: body.mobile || null,
office: body.office || null,
availability: body.availability || 'available',
clearance: body.clearance,
skills: body.skills || [],
languages: body.languages || [],
specializations: body.specializations || [],
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
syncService.queueSync('employees', 'create', newEmployee).catch(() => {})
return result
}
export async function updateEmployeeUC(req: Request, id: string, body: any, actorUserId: string) {
repoUpdate(id, {
firstName: body.firstName,
lastName: body.lastName,
position: body.position,
department: body.department,
email: body.email,
phone: body.phone,
mobile: body.mobile,
office: body.office,
availability: body.availability,
clearance: body.clearance,
skills: body.skills,
languages: body.languages,
specializations: body.specializations,
}, actorUserId)
logSecurityAudit('update', 'employees', id, actorUserId, req, 'medium')
const updatedEmployee = {
id,
firstName: body.firstName,
lastName: body.lastName,
position: body.position,
department: body.department,
email: body.email,
phone: body.phone,
mobile: body.mobile || null,
office: body.office || null,
availability: body.availability,
clearance: body.clearance,
skills: body.skills,
languages: body.languages,
specializations: body.specializations,
updatedAt: new Date().toISOString(),
updatedBy: actorUserId
}
syncService.queueSync('employees', 'update', updatedEmployee).catch(() => {})
}
export async function deleteEmployeeUC(req: Request, id: string, actorUserId: string) {
repoDelete(id)
logSecurityAudit('delete', 'employees', id, actorUserId, req, 'high')
syncService.queueSync('employees', 'delete', { id }).catch(() => {})
}

Datei anzeigen

@ -0,0 +1,48 @@
import { db } from '../config/secureDatabase'
import bcrypt from 'bcryptjs'
import { FieldEncryption } from '../services/encryption'
export async function createUserUC(input: { username: string; email: string; password: string; role: string; employeeId?: string | null }, actorRole: string) {
const assignedRole = (actorRole === 'admin') ? input.role : 'user'
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(input.username)
if (existingUser) {
const e: any = new Error('Username already exists')
e.statusCode = 409
throw e
}
const emailHash = FieldEncryption.hash(input.email)
const existingEmail = db.prepare('SELECT id FROM users WHERE email_hash = ?').get(emailHash)
if (existingEmail) {
const e: any = new Error('Email already exists')
e.statusCode = 409
throw e
}
const hashedPassword = bcrypt.hashSync(input.password, 12)
const now = new Date().toISOString()
const { v4: uuidv4 } = await import('uuid')
const userId = uuidv4()
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
input.username,
FieldEncryption.encrypt(input.email),
emailHash,
hashedPassword,
assignedRole,
input.employeeId || null,
1,
now,
now
)
return { id: userId, role: assignedRole }
}
export function updateUserRoleUC(id: string, role: 'admin' | 'superuser' | 'user') {
db.prepare(`
UPDATE users SET role = ?, updated_at = ?
WHERE id = ?
`).run(role, new Date().toISOString(), id)
}

Datei anzeigen

@ -0,0 +1,23 @@
import { body } from 'express-validator'
export const createEmployeeValidators = [
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
]
export const updateEmployeeValidators = [
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('department').notEmpty().trim().escape(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
body('availability').optional().isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
]

29
docs/ARCHITECTURE.md Normale Datei
Datei anzeigen

@ -0,0 +1,29 @@
Architecture Overview
Layers
- Domain: Types in `shared/` reused by backend/frontend.
- Use-Cases: `backend/src/usecases` (e.g., auth/loginUser, employees CRUD, users management).
- Repositories: `backend/src/repositories` do all DB access; controllers do not use SQL.
- Adapters/HTTP: `backend/src/routes/*` map HTTP <-> use-cases and validate inputs.
- Infra/Services: encryption, email, sync components, logger.
Security
- JWT required; in production `JWT_SECRET` must be set.
- Field-level encryption (AES) for sensitive data + deterministic hashes for lookups.
- Error redaction: sensitive fields are redacted in logs.
Sync
- Modular components under `backend/src/services/sync`:
- `queueStore`: DB interactions (pending, status, metadata)
- `transport`: HTTP communication between nodes
- `applier`: applies changes entity-wise
- `SyncService`: orchestrates and exposes routes
Migrations
- Simple runner: `npm run migrate` in `backend`
- Tracks applied migrations in `schema_version`; add files to `backend/scripts/migrations`.
Frontend
- API abstraction with normalized error handling
- ErrorBoundary wraps the app

81
docs/REFAKTOR_PLAN.txt Normale Datei
Datei anzeigen

@ -0,0 +1,81 @@
SkillMate – Technische Refaktorierungs- und Aufräumliste (YAGNI, funktionsgleich)
Ziele und Leitplanken
- Funktionsgleichheit: Alle bestehenden Features und APIs bleiben erhalten.
- YAGNI: Überflüssiges/ungenutztes entfernen, nur Notwendiges behalten.
- Saubere Schichtung: Controller schlank, Logik in Usecases/Repos, DB-Zugriff gekapselt.
- Konsistenz: Einheitliche Fehler- und Antwortformate, einheitliche Abhängigkeiten.
1) Standardisieren: Krypto, Auth, Konfiguration
- Bcrypt vereinheitlichen: überall „bcryptjs“ (Imports bereinigen; z. B. employeesSecure.ts).
- Secrets aus .env erzwingen (Prod): JWT_SECRET, FIELD_ENCRYPTION_KEY, DATABASE_ENCRYPTION_KEY.
- Dev-Defaults nur im Dev-Modus tolerieren; in Prod Start fehlschlagen, falls Secret fehlt.
- Einheitliche Schlüssellänge/Algorithmen dokumentieren.
- Logging sanitizen: Keine sensiblen Daten in Logs schreiben.
2) API-Verträge angleichen (Frontend/Backend)
- Employee-Suche: Backend bietet POST /api/employees/search (Body: { skills, category }). Frontend anpassen (axios.post statt GET mit q=…).
- Foto-Upload-URL: Frontend auf API_BASE_URL (VITE_API_URL + "/upload/…") umstellen, keine harte 3001-URL.
- Einheitliche Response-Form: { success, data | error } – Frontend-Services darauf normieren (Fehlerleseweg vereinheitlichen).
3) Doppelte/inkonsistente Routen und Code bereinigen
- skills.ts: doppelte Kategorie-/Subkategorie-Routen entfernen (Merge-Artefakte), nur einen konsistenten Block behalten.
- users/usersAdmin Überschneidungen prüfen; falls Dopplungen bestehen, konsolidieren.
4) DB-Zugriff konsolidieren (Repositories)
- Mitarbeiter: Routen auf employeeRepository/usecases umstellen (keine SQL in Routes).
- Skills & Users: Repositories erstellen/ergänzen; vorhandene SQL in Repos verschieben.
- Einheitliche Transaktionsgrenzen in Repos (better-sqlite3 transaction wrapper).
5) Business-Logik in Usecases
- Audit-Logging, Sync-Queueing, Feldverschlüsselung in Usecases verankern.
- Controller (Routes) nur: Validierung → Usecase-Aufruf → Mapping/Antwort.
6) Verschlüsselungsschicht klar ziehen
- sensitive Felder (email, phone, mobile, clearance_*) werden ausschließlich in einer Schicht verschlüsselt/dekryptiert (empfohlen: Repository/Usecase über encryptedDb).
- Doppelte/uneinheitliche Verschlüsselung in Routes entfernen; Hash-Felder (email_hash/phone_hash) konsistent befüllen.
7) Fehlerbehandlung & Antworten vereinheitlichen
- Fehlerformat zentral: { success: false, error: { message, details? } }.
- Validation-Fehler einheitlich aus express-validator transformieren.
- roleAuth/unauthorized Antworten an das zentrale Format anpassen.
8) Sync-Service und DB-Quelle vereinheitlichen
- syncService importiert aktuell db aus config/database; App nutzt secureDatabase. Vereinheitlichen auf secureDatabase.
- applyChanges/receiveSync prüfen: Für sensible Felder Verschlüsselung anwenden (Kompatibilität mit encryptedDb sicherstellen).
- Netzwerkknoten-Tabellen (network_nodes etc.) Existenz/Schema prüfen; andernfalls Feature als optional markieren.
9) Sicherheit & Middleware
- Helmet: In Produktion CSP aktivieren (konfigurierte Quellen), CORP/CORS restriktiver einstellen.
- Upload: Dateitypen-/Größenprüfung belassen, Fehlerpfade bereinigen (Temp-Dateien löschen).
- Static /uploads: Pfade prüfen, Directory Traversal vermeiden (path.basename & Whitelist sind vorhanden – validieren).
10) Entfernen/Ordnen von Nicht-Benötigtem (YAGNI)
- Auskommentierte/duplizierte Codeblöcke entfernen (z. B. bookings/analytics-Routen, falls ungenutzt). Alternativ klar als „optional module“ kapseln.
- Default-Admin-Erstellung an exakt einer Stelle (secureDatabase). Doppelte Erstellung in database.ts entfernen.
11) Frontend/Admin-Panel Konsistenz
- Frontend-Services: Nur axios (kein fetch-„Sonderweg“); Basis-URL zentral aus services/api.ts.
- Einheitliche 401-Behandlung (Admin-Panel-Ansatz auch im Frontend: Logout + Redirect).
- Nutzung ROLE_PERMISSIONS für UI-Gates im Admin-Panel/Frontend (Anzeige/Navigation).
12) Linting, Formatting, TS-Strictness, Basis-Tests
- ESLint + Prettier konfigurieren (Root und Projekte), CI-Checks für lint/format.
- TypeScript: strict-Optionen erhöhen, implizite anys reduzieren.
- Minimaltests (z. B. mit supertest): Auth/Login, Employees-List, Skills-Hierarchy.
13) Dokumentation & Env-Beispiele
- README: Startanleitungen (Dev/Prod), Ports, .env-Beispiele, Security-Hinweise.
- CHANGELOG für Refaktor-Schritte.
- Migrations-/Kompatibilitätsnotizen (Schemaänderungen/Hash-Felder).
14) Skripte/Starter (optional, derzeit nicht ändern)
- start-dev.cmd Port-Anzeigen weichen von realen Ports ab – als Hinweis dokumentieren, aber vorerst nicht ändern.
Priorisierung (empfohlen)
P1: (2) API-Verträge, (3) Doppelte Routen, (1) Krypto/Env, (7) Fehlerformat
P2: (4) Repos, (5) Usecases, (6) Verschlüsselungsschicht, (8) Sync-Vereinheitlichung
P3: (9) Sicherheitshärten, (11) Frontend/UX-Konsistenz, (12) Lint/Tests, (13) Doku
Hinweis
- Alle Änderungen iterativ vornehmen; nach jedem Block Smoke-Tests (Login, Employees, Skills) ausführen.

32
docs/SMOKE_TESTS.md Normale Datei
Datei anzeigen

@ -0,0 +1,32 @@
SkillMate Smoke Tests (Manual)
Environment
- Ensure backend starts: `npm run dev` in `backend`
- Ensure frontend starts: `npm run dev` in `frontend`
- Optional admin panel: `npm run dev` in `admin-panel`
Checklist
1) Auth/Login
- POST /api/auth/login with valid admin (admin/admin123)
- Expect 200, token present, user role admin
2) Employees (public list)
- GET /api/employees/public (with token)
- Expect 200, array payload
3) Employees (CRUD minimal)
- POST /api/employees with minimal valid payload
- Expect 201, id present
- GET /api/employees/:id returns same data
- PUT /api/employees/:id updates simple field, expect 200
- DELETE /api/employees/:id expect 200
4) Skills
- GET /api/skills expect 200, array
5) Settings
- GET /api/admin/settings expect 200 (with admin)
Notes
- In production, JWT_SECRET must be set or backend refuses to start.

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

Datei anzeigen

@ -10,12 +10,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@react-three/drei": "^9.88.0",
"@react-three/fiber": "^8.15.0",
"@skillmate/shared": "file:../shared", "@skillmate/shared": "file:../shared",
"@types/three": "^0.180.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"three": "^0.160.0",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {

Datei anzeigen

@ -0,0 +1,30 @@
import React from 'react'
type Props = { children: React.ReactNode }
type State = { hasError: boolean; error?: any }
export default class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: any) {
return { hasError: true, error }
}
componentDidCatch(error: any, info: any) {
// Optionally log to a service
console.error('UI ErrorBoundary:', error, info)
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 24 }}>
<h2>Ein unerwarteter Fehler ist aufgetreten.</h2>
<p>Bitte laden Sie die Seite neu oder versuchen Sie es später erneut.</p>
</div>
)
}
return this.props.children
}
}

Datei anzeigen

@ -0,0 +1,565 @@
import React, { useState, useRef, Suspense } from 'react'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { OrbitControls, Text, Box, Plane, Line, PerspectiveCamera, Sphere, Billboard, RoundedBox } from '@react-three/drei'
import * as THREE from 'three'
// Types
interface RoomData {
id: string
name: string
position: [number, number, number]
size: [number, number, number]
type: 'office' | 'meeting' | 'server' | 'kitchen' | 'restroom' | 'stairs' | 'elevator'
occupants?: string[]
}
interface FloorData {
level: number
name: string
rooms: RoomData[]
height: number
}
// Dummy employee mapping
const employeeNames: Record<string, string> = {
'emp-001': 'Max Mustermann',
'emp-002': 'Maria Schmidt',
'emp-003': 'Thomas Weber',
'emp-004': 'Sarah Fischer',
'emp-005': 'Michael Bauer',
'emp-006': 'Julia Wagner',
'emp-007': 'Andreas Becker',
'emp-008': 'Lisa Hoffmann',
}
// Room type colors
const roomColors = {
office: '#3B82F6',
meeting: '#10B981',
server: '#6B7280',
kitchen: '#F59E0B',
restroom: '#9CA3AF',
stairs: '#8B5CF6',
elevator: '#EC4899',
}
// Building data with 5 floors
const buildingData: FloorData[] = [
{
level: 0,
name: 'Erdgeschoss',
height: 0,
rooms: [
{ id: 'EG-001', name: 'Empfang', position: [-3, 0.5, 2], size: [2, 1, 2], type: 'office' },
{ id: 'EG-002', name: 'Kantine', position: [0, 0.5, 2], size: [3, 1, 2], type: 'kitchen' },
{ id: 'EG-003', name: 'Meeting 1', position: [3, 0.5, 2], size: [2, 1, 2], type: 'meeting' },
{ id: 'EG-S1', name: 'Treppe', position: [-4.5, 0.5, -2], size: [1, 1, 1], type: 'stairs' },
{ id: 'EG-E1', name: 'Aufzug', position: [4.5, 0.5, -2], size: [1, 1, 1], type: 'elevator' },
]
},
{
level: 1,
name: '1. OG - Verwaltung',
height: 3,
rooms: [
{ id: '1-101', name: 'Büro 101', position: [-3, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-001'] },
{ id: '1-102', name: 'Büro 102', position: [0, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-002'] },
{ id: '1-103', name: 'Büro 103', position: [3, 3.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-003'] },
{ id: '1-Server', name: 'Server', position: [0, 3.5, -2], size: [2, 1, 1.5], type: 'server' },
{ id: '1-S1', name: 'Treppe', position: [-4.5, 3.5, -2], size: [1, 1, 1], type: 'stairs' },
{ id: '1-E1', name: 'Aufzug', position: [4.5, 3.5, -2], size: [1, 1, 1], type: 'elevator' },
]
},
{
level: 2,
name: '2. OG - Kriminalpolizei',
height: 6,
rooms: [
{ id: '2-201', name: 'Büro 201', position: [-3, 6.5, 2], size: [2, 1, 2], type: 'office', occupants: ['emp-004'] },
{ id: '2-202', name: 'Großraum', position: [0, 6.5, 2], size: [4, 1, 2], type: 'office', occupants: ['emp-005', 'emp-006'] },
{ id: '2-203', name: 'Einsatzzentrale', position: [0, 6.5, -2], size: [3, 1, 1.5], type: 'meeting' },
{ id: '2-S1', name: 'Treppe', position: [-4.5, 6.5, -2], size: [1, 1, 1], type: 'stairs' },
{ id: '2-E1', name: 'Aufzug', position: [4.5, 6.5, -2], size: [1, 1, 1], type: 'elevator' },
]
},
{
level: 3,
name: '3. OG - Staatsschutz',
height: 9,
rooms: [
{ id: '3-301', name: 'Büro 301', position: [-3, 9.5, 2], size: [2, 1, 2], type: 'office' },
{ id: '3-302', name: 'Büro 302', position: [0, 9.5, 2], size: [2, 1, 2], type: 'office' },
{ id: '3-303', name: 'Abhörsicher', position: [3, 9.5, 2], size: [2, 1, 2], type: 'meeting' },
{ id: '3-Server', name: 'Server', position: [0, 9.5, -2], size: [2, 1, 1.5], type: 'server' },
{ id: '3-S1', name: 'Treppe', position: [-4.5, 9.5, -2], size: [1, 1, 1], type: 'stairs' },
{ id: '3-E1', name: 'Aufzug', position: [4.5, 9.5, -2], size: [1, 1, 1], type: 'elevator' },
]
},
{
level: 4,
name: '4. OG - Cybercrime & IT',
height: 12,
rooms: [
{ id: '4-401', name: 'Cybercrime Team', position: [-2, 12.5, 2], size: [4, 1, 2], type: 'office', occupants: ['emp-007', 'emp-008'] },
{ id: '4-402', name: 'IT-Forensik', position: [3, 12.5, 2], size: [2, 1, 2], type: 'office' },
{ id: '4-Server', name: 'Hauptserver', position: [0, 12.5, -2], size: [3, 1, 1.5], type: 'server' },
{ id: '4-S1', name: 'Treppe', position: [-4.5, 12.5, -2], size: [1, 1, 1], type: 'stairs' },
{ id: '4-E1', name: 'Aufzug', position: [4.5, 12.5, -2], size: [1, 1, 1], type: 'elevator' },
]
},
]
// Room Component
function Room({ room, isSelected, isStart, isEnd, onSelect }: {
room: RoomData
isSelected: boolean
isStart?: boolean
isEnd?: boolean
onSelect: () => void
}) {
const meshRef = useRef<THREE.Mesh>(null)
const [hovered, setHovered] = useState(false)
useFrame((state) => {
if (meshRef.current) {
// Gentle floating animation for selected room
if (isSelected) {
meshRef.current.position.y = room.position[1] + Math.sin(state.clock.elapsedTime * 2) * 0.05
}
}
})
const color = isStart ? '#10B981' : isEnd ? '#EF4444' : isSelected ? '#FCD34D' : (hovered ? '#FCA5A5' : roomColors[room.type])
return (
<group>
<Box
ref={meshRef}
args={room.size}
position={room.position}
onClick={onSelect}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<meshStandardMaterial
color={color}
transparent
opacity={isStart || isEnd ? 0.9 : 0.7}
emissive={isStart ? '#10B981' : isEnd ? '#EF4444' : isSelected ? '#FCD34D' : '#000000'}
emissiveIntensity={isStart || isEnd ? 0.3 : isSelected ? 0.2 : 0}
/>
</Box>
{/* Room label with billboard and background */}
<Billboard
follow={true}
lockX={false}
lockY={false}
lockZ={false}
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.2, room.position[2]]}
>
<RoundedBox args={[room.name.length * 0.08, 0.25, 0.01]} radius={0.02}>
<meshBasicMaterial color="#1F2937" opacity={0.9} transparent />
</RoundedBox>
<Text
fontSize={0.15}
color="white"
anchorX="center"
anchorY="middle"
position={[0, 0, 0.01]}
>
{room.name}
</Text>
</Billboard>
{/* Occupant count with billboard and background */}
{room.occupants && room.occupants.length > 0 && !isStart && !isEnd && (
<Billboard
follow={true}
lockX={false}
lockY={false}
lockZ={false}
position={[room.position[0], room.position[1] - room.size[1]/2 - 0.2, room.position[2]]}
>
<RoundedBox args={[1.0, 0.2, 0.01]} radius={0.02}>
<meshBasicMaterial color="#1F2937" opacity={0.8} transparent />
</RoundedBox>
<Text
fontSize={0.12}
color="#FFD700"
anchorX="center"
anchorY="middle"
position={[0, 0, 0.01]}
>
{room.occupants.length} Person(en)
</Text>
</Billboard>
)}
{/* Start/End markers with billboard and background */}
{isStart && (
<Billboard
follow={true}
lockX={false}
lockY={false}
lockZ={false}
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.5, room.position[2]]}
>
<RoundedBox args={[0.8, 0.35, 0.01]} radius={0.05}>
<meshBasicMaterial color="#10B981" opacity={0.9} transparent />
</RoundedBox>
<Text
fontSize={0.25}
color="white"
anchorX="center"
anchorY="middle"
position={[0, 0, 0.01]}
outlineWidth={0.02}
outlineColor="#065F46"
>
START
</Text>
</Billboard>
)}
{isEnd && (
<Billboard
follow={true}
lockX={false}
lockY={false}
lockZ={false}
position={[room.position[0], room.position[1] + room.size[1]/2 + 0.5, room.position[2]]}
>
<RoundedBox args={[0.6, 0.35, 0.01]} radius={0.05}>
<meshBasicMaterial color="#EF4444" opacity={0.9} transparent />
</RoundedBox>
<Text
fontSize={0.25}
color="white"
anchorX="center"
anchorY="middle"
position={[0, 0, 0.01]}
outlineWidth={0.02}
outlineColor="#7F1D1D"
>
ZIEL
</Text>
</Billboard>
)}
</group>
)
}
// Floor Component
function Floor({ floor, visible, opacity, selectedRoom, startRoom, endRoom, onSelectRoom }: {
floor: FloorData
visible: boolean
opacity: number
selectedRoom: string | null
startRoom: string | null
endRoom: string | null
onSelectRoom: (roomId: string) => void
}) {
if (!visible) return null
return (
<group>
{/* Floor plate */}
<Plane
args={[12, 8]}
rotation={[-Math.PI / 2, 0, 0]}
position={[0, floor.height, 0]}
>
<meshStandardMaterial
color="#E5E7EB"
transparent
opacity={opacity * 0.3}
side={THREE.DoubleSide}
/>
</Plane>
{/* Floor label with billboard and background */}
<Billboard
follow={true}
lockX={false}
lockY={false}
lockZ={false}
position={[-7, floor.height + 0.5, 5]}
>
<RoundedBox args={[floor.name.length * 0.11, 0.4, 0.02]} radius={0.05}>
<meshBasicMaterial color="#111827" opacity={0.95} transparent />
</RoundedBox>
<Text
fontSize={0.25}
color="white"
anchorX="center"
anchorY="middle"
position={[0, 0, 0.02]}
outlineWidth={0.015}
outlineColor="#000000"
>
{floor.name}
</Text>
</Billboard>
{/* Rooms */}
{floor.rooms.map(room => (
<Room
key={room.id}
room={room}
isSelected={selectedRoom === room.id}
isStart={startRoom === room.id}
isEnd={endRoom === room.id}
onSelect={() => onSelectRoom(room.id)}
/>
))}
</group>
)
}
// Camera Controller Component - removed to allow free orbit controls
// Main 3D Office Map Component
interface OfficeMap3DProps {
targetEmployeeId?: string
targetRoom?: string
currentUserRoom?: string // Room of logged-in user
onClose?: () => void
}
export default function OfficeMap3D({ targetEmployeeId, targetRoom, currentUserRoom, onClose }: OfficeMap3DProps) {
const [selectedFloor, setSelectedFloor] = useState(0)
const [selectedRoom, setSelectedRoom] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'all' | 'single'>('all')
const [startRoom, setStartRoom] = useState<string | null>(currentUserRoom || null)
const [endRoom, setEndRoom] = useState<string | null>(null)
// Find target room/floor
React.useEffect(() => {
if (targetEmployeeId || targetRoom) {
for (const floor of buildingData) {
for (const room of floor.rooms) {
if (targetRoom === room.id || room.occupants?.includes(targetEmployeeId || '')) {
setEndRoom(room.id)
setSelectedFloor(floor.level)
setSelectedRoom(room.id)
setViewMode('single')
break
}
}
}
}
}, [targetEmployeeId, targetRoom])
const handleSelectFloor = (level: number) => {
setSelectedFloor(level)
setViewMode('single')
}
const getRoomDetails = () => {
if (!selectedRoom) return null
for (const floor of buildingData) {
const room = floor.rooms.find(r => r.id === selectedRoom)
if (room) {
return {
...room,
floorName: floor.name,
occupantNames: room.occupants?.map(id => employeeNames[id] || id)
}
}
}
return null
}
const roomDetails = getRoomDetails()
return (
<div className="bg-gray-900 rounded-lg flex flex-col" style={{ height: '700px' }}>
{/* Top bar with horizontal floor controls */}
<div className="p-4 pb-2">
<div className="flex justify-between items-center">
<div className="flex gap-2">
{/* Floor buttons - horizontal */}
<button
onClick={() => setViewMode('all')}
className={`px-3 py-1 rounded ${viewMode === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
Alle Etagen
</button>
{buildingData.map(floor => (
<button
key={floor.level}
onClick={() => handleSelectFloor(floor.level)}
className={`px-3 py-1 rounded ${
viewMode === 'single' && selectedFloor === floor.level
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300'
}`}
>
{floor.level === 0 ? 'EG' : `${floor.level}. OG`}
</button>
))}
</div>
<div className="text-white text-sm">
Maus zum Drehen/Zoomen | Klick für Details
</div>
</div>
</div>
{/* Main content - room details left, 3D canvas right */}
<div className="flex-1 flex gap-4 px-4 pb-4" style={{ minHeight: 0 }}>
{/* Left side - Room details */}
<div className="flex-shrink-0" style={{ width: '250px' }}>
{roomDetails ? (
<div className="p-3 bg-gray-800 rounded text-white h-full">
<div className="flex justify-between items-start mb-2">
<h4 className="font-semibold">{roomDetails.name}</h4>
<span className={`px-2 py-1 rounded text-xs bg-opacity-20 ${
roomDetails.type === 'office' ? 'bg-blue-500 text-blue-300' :
roomDetails.type === 'meeting' ? 'bg-green-500 text-green-300' :
roomDetails.type === 'server' ? 'bg-gray-500 text-gray-300' :
'bg-yellow-500 text-yellow-300'
}`}>
{roomDetails.type}
</span>
</div>
<p className="text-sm text-gray-400">{roomDetails.floorName}</p>
{roomDetails.occupantNames && roomDetails.occupantNames.length > 0 && (
<div className="mt-2">
<p className="text-sm text-gray-400">Mitarbeiter:</p>
{roomDetails.occupantNames.map((name, i) => (
<p key={i} className="text-sm"> {name}</p>
))}
</div>
)}
</div>
) : (
<div className="p-3 bg-gray-800 rounded text-gray-400 text-sm h-full">
Klicken Sie auf einen Raum für Details
</div>
)}
</div>
{/* Right side - 3D Canvas */}
<div className="flex-1 bg-gray-800 rounded overflow-hidden">
<Canvas shadows camera={{ position: [20, 20, 20], fov: 50 }}>
<Suspense fallback={null}>
{/* Lighting */}
<ambientLight intensity={0.5} />
<pointLight position={[10, 20, 10]} intensity={1} castShadow />
<directionalLight position={[0, 10, 5]} intensity={0.5} castShadow />
{/* Grid */}
<gridHelper args={[20, 20]} position={[0, 0, 0]} />
{/* Building floors */}
{buildingData.map(floor => (
<Floor
key={floor.level}
floor={floor}
visible={viewMode === 'all' || floor.level === selectedFloor}
opacity={viewMode === 'all' ? (floor.level === selectedFloor ? 1 : 0.3) : 1}
selectedRoom={selectedRoom}
startRoom={startRoom}
endRoom={endRoom}
onSelectRoom={setSelectedRoom}
/>
))}
{/* Connecting elements (stairs/elevator shafts) */}
{viewMode === 'all' && (
<group>
{/* Elevator shaft */}
<Line
points={[[4.5, 0, -2], [4.5, 13, -2]]}
color="#EC4899"
lineWidth={3}
/>
{/* Stair shaft */}
<Line
points={[[-4.5, 0, -2], [-4.5, 13, -2]]}
color="#8B5CF6"
lineWidth={3}
/>
</group>
)}
{/* Path visualization between start and end */}
{startRoom && endRoom && (() => {
let startPos: [number, number, number] | null = null
let endPos: [number, number, number] | null = null
let startFloor = -1
let endFloor = -1
// Find positions
for (const floor of buildingData) {
for (const room of floor.rooms) {
if (room.id === startRoom) {
startPos = room.position
startFloor = floor.level
}
if (room.id === endRoom) {
endPos = room.position
endFloor = floor.level
}
}
}
if (startPos && endPos) {
const points: [number, number, number][] = []
if (startFloor === endFloor) {
// Same floor - direct path
points.push(startPos)
points.push(endPos)
} else {
// Different floors - path through elevator
points.push(startPos)
// Go to elevator on start floor
points.push([4.5, startPos[1], -2])
// Move to destination floor
points.push([4.5, endPos[1], -2])
// Go to destination
points.push(endPos)
}
return (
<group>
<Line
points={points}
color="#FCD34D"
lineWidth={4}
dashed
dashScale={5}
dashSize={0.5}
gapSize={0.5}
/>
{/* Animated sphere along path */}
<Sphere args={[0.2]} position={startPos}>
<meshBasicMaterial color="#FCD34D" />
</Sphere>
</group>
)
}
return null
})()}
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={3}
maxDistance={50}
maxPolarAngle={Math.PI / 2.5}
zoomToCursor={true}
panSpeed={0.8}
rotateSpeed={0.8}
/>
</Suspense>
</Canvas>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,98 @@
import React from 'react'
import OfficeMap3D from './OfficeMap3D'
import { X } from 'lucide-react'
interface OfficeMapModalProps {
isOpen: boolean
onClose: () => void
targetEmployeeId?: string
targetRoom?: string
currentUserRoom?: string
employeeName?: string
}
export default function OfficeMapModal({
isOpen,
onClose,
targetEmployeeId,
targetRoom,
currentUserRoom,
employeeName
}: OfficeMapModalProps) {
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-gray-900 rounded-lg shadow-xl w-full mx-auto" style={{ maxWidth: '1400px' }}>
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-gray-700">
<div>
<h2 className="text-xl font-semibold text-white">
Wegbeschreibung zu {employeeName || 'Mitarbeiter'}
</h2>
<p className="text-sm text-gray-400 mt-1">
<span className="text-green-400"> Start:</span> Ihr Büro
{' | '}
<span className="text-red-400"> Ziel:</span> {targetRoom || 'Zielbüro'}
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Map Content */}
<OfficeMap3D
targetEmployeeId={targetEmployeeId}
targetRoom={targetRoom}
currentUserRoom={currentUserRoom}
onClose={onClose}
/>
{/* Footer with legend */}
<div className="p-4 border-t border-gray-700">
<div className="flex justify-between items-center">
<div className="flex gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span className="text-gray-300">Ihr Standort</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded"></div>
<span className="text-gray-300">Ziel</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<span className="text-gray-300">Büro</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-purple-500 rounded"></div>
<span className="text-gray-300">Treppe/Aufzug</span>
</div>
</div>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
</div>
</div>
</>
)
}

Datei anzeigen

@ -3,13 +3,17 @@ import { useState, useEffect } from 'react'
import type { Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
// Dynamische Hierarchie für Darstellung der Nutzer-Skills // Dynamische Hierarchie für Darstellung der Nutzer-Skills
import SkillLevelBar from '../components/SkillLevelBar' import SkillLevelBar from '../components/SkillLevelBar'
import OfficeMapModal from '../components/OfficeMapModal'
import { employeeApi } from '../services/api' import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
export default function EmployeeDetail() { export default function EmployeeDetail() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuthStore()
const [employee, setEmployee] = useState<Employee | null>(null) const [employee, setEmployee] = useState<Employee | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showOfficeMap, setShowOfficeMap] = useState(false)
const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([]) const [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 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 PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
@ -141,10 +145,17 @@ export default function EmployeeDetail() {
<div> <div>
<span className="text-tertiary">Büro:</span> <span className="text-tertiary">Büro:</span>
<p className="text-secondary">{employee.office}</p> <p className="text-secondary">{employee.office}</p>
<button
onClick={() => setShowOfficeMap(!showOfficeMap)}
className="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
{showOfficeMap ? 'Karte ausblenden' : 'Büro zeigen'}
</button>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
@ -227,6 +238,16 @@ export default function EmployeeDetail() {
)} )}
</div> </div>
</div> </div>
{/* Office Map Modal */}
<OfficeMapModal
isOpen={showOfficeMap}
onClose={() => setShowOfficeMap(false)}
targetEmployeeId={employee?.id}
targetRoom={employee?.office || undefined}
currentUserRoom="1-101" // This should come from logged-in user data
employeeName={employee ? `${employee.firstName} ${employee.lastName}` : undefined}
/>
</div> </div>
) )
} }

Datei anzeigen

@ -4,137 +4,253 @@ import { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard' import EmployeeCard from '../components/EmployeeCard'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { employeeApi } from '../services/api' import { employeeApi } from '../services/api'
// Import the skill hierarchy - we'll load it dynamically in useEffect
type SkillWithStats = {
id: string
name: string
category: string
subcategory: string
userCount: number
levelDistribution: {
beginner: number
intermediate: number
expert: number
}
}
export default function SkillSearch() { export default function SkillSearch() {
const navigate = useNavigate() const navigate = useNavigate()
const [selectedCategory, setSelectedCategory] = useState('') const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set())
const [selectedSubCategory, setSelectedSubCategory] = useState('') const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set())
const [selectedSkills, setSelectedSkills] = useState<string[]>([])
const [freeSearchTerm, setFreeSearchTerm] = useState('') const [freeSearchTerm, setFreeSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<Employee[]>([]) const [filteredEmployees, setFilteredEmployees] = useState<Employee[]>([])
const [hasSearched, setHasSearched] = useState(false) const [allEmployees, setAllEmployees] = useState<Employee[]>([])
const [loading, setLoading] = 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 }[] }[] }[]>([]) const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
const [allSkillsWithStats, setAllSkillsWithStats] = useState<SkillWithStats[]>([])
const [searchSuggestions, setSearchSuggestions] = useState<string[]>([])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
// Try to load static skill hierarchy
let SKILL_HIERARCHY: any[] = []
try {
const skillModule = await import('../../../shared/skills.js')
SKILL_HIERARCHY = skillModule.SKILL_HIERARCHY || []
} catch {
console.log('Could not load static skill hierarchy')
}
// Load hierarchy from API
const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', { const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}) })
const data = await res.json() const data = await res.json()
if (data?.success) setHierarchy(data.data || []) if (data?.success) {
// Use API data if available, otherwise fallback to static hierarchy
const apiHierarchy = data.data || []
const combinedHierarchy = apiHierarchy.length > 0 ? apiHierarchy : SKILL_HIERARCHY
setHierarchy(combinedHierarchy)
// Calculate skill stats from employees
const empRes = await employeeApi.getAll()
setAllEmployees(empRes) // Store all employees for filtering
const skillStats: Record<string, SkillWithStats> = {}
// Process hierarchy to get all skills - use combined hierarchy
const hierarchyData = combinedHierarchy
hierarchyData.forEach((cat: any) => {
cat.subcategories?.forEach((sub: any) => {
sub.skills?.forEach((skill: any) => {
skillStats[skill.id] = {
id: skill.id,
name: skill.name,
category: cat.id, // Use category ID to match selectedCategories
subcategory: sub.name,
userCount: 0,
levelDistribution: { beginner: 0, intermediate: 0, expert: 0 }
}
})
})
})
// Count employees and levels
empRes.forEach((emp: any) => {
emp.skills?.forEach((skill: any) => {
if (skillStats[skill.id]) {
skillStats[skill.id].userCount++
const level = parseInt(skill.level) || 0
if (level >= 1 && level <= 3) {
skillStats[skill.id].levelDistribution.beginner++
} else if (level >= 4 && level <= 6) {
skillStats[skill.id].levelDistribution.intermediate++
} else if (level >= 7 && level <= 10) {
skillStats[skill.id].levelDistribution.expert++
}
}
})
})
setAllSkillsWithStats(Object.values(skillStats))
}
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
load() load()
}, []) }, [])
const handleCategoryChange = (categoryId: string) => { // Live search suggestions
setSelectedCategory(categoryId) useEffect(() => {
setSelectedSubCategory('') if (freeSearchTerm.length > 1) {
setSelectedSkills([]) const suggestions: string[] = []
}
const handleSubCategoryChange = (subCategoryId: string) => { // Search in skills
setSelectedSubCategory(subCategoryId) allSkillsWithStats.forEach(skill => {
setSelectedSkills([]) if (skill.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
} suggestions.push(skill.name)
}
})
const handleSkillToggle = (skillId: string) => { // Search in categories
setSelectedSkills(prev => hierarchy.forEach(cat => {
prev.includes(skillId) if (cat.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
? prev.filter(s => s !== skillId) suggestions.push(cat.name)
: [...prev, skillId] }
) cat.subcategories?.forEach(sub => {
} if (sub.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
suggestions.push(sub.name)
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) setSearchSuggestions(suggestions.slice(0, 5))
if (selectedSkills.length > 0) { } else {
results = results.filter(employee => { setSearchSuggestions([])
return selectedSkills.some(skillId => { }
// Check in skills array by ID }, [freeSearchTerm, allSkillsWithStats, hierarchy])
if (employee.skills?.some((skill: any) => skill.id === skillId)) {
return true
}
// Ignore languages/specializations here (catalog-driven search)
return false
})
})
}
setSearchResults(results) const toggleCategory = (categoryId: string) => {
} catch (error) { const newSelection = new Set(selectedCategories)
console.error('Search failed:', error) if (newSelection.has(categoryId)) {
setSearchResults([]) newSelection.delete(categoryId)
} finally { if (newSelection.size === 0) {
setLoading(false) setSelectedSkills(new Set())
setFilteredEmployees([])
}
} else {
newSelection.add(categoryId)
}
setSelectedCategories(newSelection)
}
// Toggle skill selection and filter employees
const handleSkillClick = (skillId: string) => {
const newSelection = new Set(selectedSkills)
if (newSelection.has(skillId)) {
newSelection.delete(skillId)
} else {
newSelection.add(skillId)
}
setSelectedSkills(newSelection)
// Filter employees who have ANY of the selected skills
if (newSelection.size === 0) {
setFilteredEmployees([])
} else {
const filtered = allEmployees.filter(employee =>
Array.from(newSelection).some(selectedId =>
employee.skills?.some((skill: any) => skill.id === selectedId)
)
).map(emp => {
// Find highest skill level among selected skills
const selectedSkillLevels = Array.from(newSelection)
.map(selectedId => emp.skills?.find((s: any) => s.id === selectedId)?.level || 0)
.map(level => parseInt(level) || 0)
const maxLevel = Math.max(...selectedSkillLevels, 0)
return {
...emp,
selectedSkillLevel: maxLevel,
matchedSkillsCount: selectedSkillLevels.filter(l => l > 0).length
}
}).sort((a: any, b: any) => {
// Sort by number of matched skills, then by highest level
if (a.matchedSkillsCount !== b.matchedSkillsCount) {
return b.matchedSkillsCount - a.matchedSkillsCount
}
return b.selectedSkillLevel - a.selectedSkillLevel
})
setFilteredEmployees(filtered)
} }
} }
// Live search as user types
useEffect(() => {
if (freeSearchTerm.length > 2) {
const searchLower = freeSearchTerm.toLowerCase()
const results = allEmployees.filter(employee => {
// Search in name, department, position
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
employee.department?.toLowerCase().includes(searchLower) ||
employee.position?.toLowerCase().includes(searchLower)) {
return true
}
// Search in skills
if (employee.skills?.some((skill: any) => skill.name?.toLowerCase().includes(searchLower))) {
return true
}
// Search in specializations
if (employee.specializations?.some((spec: any) => spec?.toLowerCase().includes(searchLower))) {
return true
}
return false
})
setFilteredEmployees(results)
} else if (freeSearchTerm.length === 0 && selectedSkills.size > 0) {
// If search is cleared, reapply skill filters
const filtered = allEmployees.filter(employee =>
Array.from(selectedSkills).some(selectedId =>
employee.skills?.some((skill: any) => skill.id === selectedId)
)
)
setFilteredEmployees(filtered)
}
}, [freeSearchTerm, allEmployees])
const handleReset = () => { const handleReset = () => {
setSelectedCategory('') setSelectedCategories(new Set())
setSelectedSubCategory('') setSelectedSkills(new Set())
setSelectedSkills([])
setFreeSearchTerm('') setFreeSearchTerm('')
setSearchResults([]) setFilteredEmployees([])
setHasSearched(false) setSearchSuggestions([])
} }
const getSubCategories = () => { // Category colors and icons
if (!selectedCategory) return [] const categoryConfig: Record<string, { icon: string; color: string }> = {
const category = hierarchy.find(cat => cat.id === selectedCategory) 'communication': { icon: '💬', color: 'bg-blue-100 text-blue-800 border-blue-300' },
return category?.subcategories || [] 'technical': { icon: '💻', color: 'bg-green-100 text-green-800 border-green-300' },
'operational': { icon: '🎯', color: 'bg-orange-100 text-orange-800 border-orange-300' },
'analytical': { icon: '📊', color: 'bg-purple-100 text-purple-800 border-purple-300' },
'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
} }
const getSkills = () => { const getCategoryConfig = (categoryName: string) => {
if (!selectedCategory || !selectedSubCategory) return [] const key = categoryName.toLowerCase().replace(/[^a-z]/g, '')
const category = hierarchy.find(cat => cat.id === selectedCategory) return categoryConfig[key] || { icon: '📁', color: 'bg-gray-100 text-gray-800 border-gray-300' }
const subCategory = category?.subcategories.find(sub => sub.id === selectedSubCategory) }
return subCategory?.skills || []
const getFilteredSkills = () => {
if (selectedCategories.size === 0) return []
return allSkillsWithStats.filter(skill =>
selectedCategories.has(skill.category)
).sort((a, b) => b.userCount - a.userCount)
} }
const getSkillNameById = (skillId: string) => { const getSkillNameById = (skillId: string) => {
@ -150,21 +266,21 @@ export default function SkillSearch() {
return ( return (
<div> <div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8"> <h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
Skill-Suche Skill-Explorer
</h1> </h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="card"> <div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4"> <h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Suchkriterien Filter
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
{/* Freie Suche */} {/* Freie Suche mit Live-Vorschlägen */}
<div> <div>
<label className="block text-body font-medium text-secondary mb-2"> <label className="block text-body font-medium text-secondary mb-2">
Freie Suche Intelligente Suche
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@ -173,147 +289,308 @@ export default function SkillSearch() {
onChange={(e) => setFreeSearchTerm(e.target.value)} onChange={(e) => setFreeSearchTerm(e.target.value)}
placeholder="Name, Skill, Abteilung..." placeholder="Name, Skill, Abteilung..."
className="input-field w-full pl-10" className="input-field w-full pl-10"
autoComplete="off"
/> />
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-placeholder" /> <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-placeholder" />
{/* Live-Vorschläge */}
{searchSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-border-default rounded-lg shadow-lg">
{searchSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => {
setFreeSearchTerm(suggestion)
setSearchSuggestions([])
}}
className="w-full text-left px-4 py-2 hover:bg-bg-accent text-secondary hover:text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
{/* Kategorie-Auswahl */} {/* Kategorie Filter-Bubbles */}
<div> <div>
<label className="block text-body font-medium text-secondary mb-2"> <label className="block text-body font-medium text-secondary mb-2">
Skill-Kategorie Kategorien (Mehrfachauswahl)
</label> </label>
<select <div className="flex flex-wrap gap-2">
value={selectedCategory} {hierarchy.map((category) => {
onChange={(e) => handleCategoryChange(e.target.value)} const config = getCategoryConfig(category.name)
className="input-field w-full" const isSelected = selectedCategories.has(category.id) // Use ID for checking
> return (
<option value="">Alle Kategorien</option> <button
{hierarchy.map((category) => ( key={category.id}
<option key={category.id} value={category.id}>{category.name}</option> onClick={() => toggleCategory(category.id)} // Pass ID instead of name
))} className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all flex items-center gap-1 border-2 ${
</select> isSelected
? config.color + ' shadow-md scale-105'
: 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600'
}`}
>
<span>{config.icon}</span>
<span>{category.name}</span>
{isSelected && <span className="ml-1"></span>}
</button>
)
})}
</div>
</div> </div>
{/* Unterkategorie-Auswahl */} {/* Remove old skill grid from here - will be in middle column */}
{selectedCategory && ( {false && (
<div> <div>
<label className="block text-body font-medium text-secondary mb-2"> <label className="block text-body font-medium text-secondary mb-2">
Unterkategorie Verfügbare Skills ({getFilteredSkills().length})
</label> </label>
<select <div className="border border-border-default rounded-card p-3 max-h-96 overflow-y-auto">
value={selectedSubCategory} <div className="grid grid-cols-1 gap-2">
onChange={(e) => handleSubCategoryChange(e.target.value)} {getFilteredSkills().map((skill) => {
className="input-field w-full" const isSelected = selectedSkills.includes(skill.id)
> const dist = skill.levelDistribution
<option value="">Alle Unterkategorien</option> return (
{getSubCategories().map((subCategory) => ( <button
<option key={subCategory.id} value={subCategory.id}>{subCategory.name}</option> key={skill.id}
))} onClick={() => handleSkillToggle(skill.id)}
</select> className={`p-3 rounded-lg border-2 transition-all text-left ${
</div> isSelected
)} ? 'bg-blue-50 border-blue-400 dark:bg-blue-900/20 dark:border-blue-500'
: 'bg-white border-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-primary">{skill.name}</span>
<span className="ml-2 text-xs text-tertiary">({skill.subcategory})</span>
</div>
{isSelected && <span className="text-blue-600 text-lg"></span>}
</div>
{/* Skill-Auswahl */} {/* Level-Verteilung mit geometrischen Formen */}
{selectedSubCategory && ( <div className="flex items-center gap-3">
<div> <div className="flex gap-2">
<label className="block text-body font-medium text-secondary mb-2"> {dist.beginner > 0 && (
Skills auswählen <div className="relative w-6 h-6 bg-red-500 rounded-full flex items-center justify-center" title={`${dist.beginner} Anfänger`}>
</label> <span className="text-white text-xs font-bold">{dist.beginner}</span>
<div className="space-y-2 max-h-64 overflow-y-auto border border-border-default rounded-card p-3"> </div>
{getSkills().map((skill) => ( )}
<label key={skill.id} className="flex items-center space-x-2 cursor-pointer hover:bg-bg-accent p-1 rounded"> {dist.intermediate > 0 && (
<input <div className="relative w-6 h-6 bg-green-500 flex items-center justify-center" title={`${dist.intermediate} Fortgeschrittene`}>
type="checkbox" <span className="text-white text-xs font-bold">{dist.intermediate}</span>
checked={selectedSkills.includes(skill.id)} </div>
onChange={() => handleSkillToggle(skill.id)} )}
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue" {dist.expert > 0 && (
/> <div className="relative flex items-center justify-center" title={`${dist.expert} Experten`}>
<span className="text-body text-secondary">{skill.name}</span> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
</label> <path
))} d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
fill="rgb(147 51 234)"
/>
</svg>
<span className="absolute text-white text-xs font-bold" style={{ fontSize: '10px' }}>{dist.expert}</span>
</div>
)}
</div>
<span className="text-xs text-tertiary ml-auto">
{skill.userCount} Mitarbeiter
</span>
</div>
</button>
)
})}
</div>
</div> </div>
</div> </div>
)} )}
<div className="flex space-x-2 pt-4"> <button
<button onClick={handleReset}
onClick={handleSearch} className="w-full btn-secondary"
disabled={loading || (!freeSearchTerm && selectedSkills.length === 0)} >
className="flex-1 btn-primary disabled:opacity-50 disabled:cursor-not-allowed" Filter zurücksetzen
> </button>
<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>
</div> </div>
{selectedSkills.length > 0 && ( {selectedSkills.size > 0 && (
<div className="card mt-6"> <div className="card mt-6">
<h3 className="text-body font-poppins font-semibold text-primary mb-3"> <h3 className="text-body font-poppins font-semibold text-primary mb-3">
Ausgewählte Skills ({selectedSkills.length}) Ausgewählte Skills ({selectedSkills.size})
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="space-y-2">
{selectedSkills.map((skillId) => ( {Array.from(selectedSkills).map(skillId => (
<span key={skillId} className="badge badge-info"> <div key={skillId} className="flex items-center justify-between">
{getSkillNameById(skillId)} <span className="badge badge-info text-xs">
{getSkillNameById(skillId)}
</span>
<button <button
onClick={() => handleSkillToggle(skillId)} onClick={() => handleSkillClick(skillId)}
className="ml-2 text-xs hover:text-error" className="text-xs text-error hover:text-red-700"
> >
× ×
</button> </button>
</span> </div>
))} ))}
</div> </div>
<button
onClick={() => {
setSelectedSkills(new Set())
setFilteredEmployees([])
}}
className="w-full mt-3 text-xs btn-secondary"
>
Alle abwählen
</button>
</div> </div>
)} )}
</div> </div>
{/* Middle column - Skills */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="card"> <div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4"> <h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Suchergebnisse Verfügbare Skills
{hasSearched && searchResults.length > 0 && ( {selectedCategories.size > 0 && (
<span className="ml-2 text-body font-normal text-tertiary"> <span className="ml-2 text-body font-normal text-tertiary">
({searchResults.length} Mitarbeiter gefunden) ({getFilteredSkills().length} Skills)
</span> </span>
)} )}
</h2> </h2>
{!hasSearched ? ( {selectedCategories.size === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<SearchIcon className="w-16 h-16 mx-auto text-text-placeholder mb-4" />
<p className="text-tertiary"> <p className="text-tertiary">
Geben Sie einen Suchbegriff ein oder wählen Sie Skills aus Wählen Sie eine Kategorie aus
</p> </p>
</div> </div>
) : loading ? ( ) : (
<div className="text-center py-12"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[600px] overflow-y-auto">
<p className="text-tertiary">Suche läuft...</p> {getFilteredSkills().map((skill) => {
const isSelected = selectedSkills.has(skill.id)
const dist = skill.levelDistribution
return (
<button
key={skill.id}
onClick={() => handleSkillClick(skill.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
isSelected
? 'bg-blue-50 border-blue-400 shadow-md dark:bg-blue-900/20 dark:border-blue-500'
: 'bg-white border-gray-200 hover:bg-gray-50 hover:border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-primary">{skill.name}</span>
{isSelected && <span className="ml-2 text-blue-600"></span>}
</div>
</div>
{/* Level-Verteilung mit geometrischen Formen */}
<div className="flex items-center gap-3">
<div className="flex gap-2">
{dist.beginner > 0 && (
<div className="relative w-6 h-6 bg-red-500 rounded-full flex items-center justify-center" title={`${dist.beginner} Anfänger`}>
<span className="text-white text-xs font-bold">{dist.beginner}</span>
</div>
)}
{dist.intermediate > 0 && (
<div className="relative w-6 h-6 bg-green-500 flex items-center justify-center" title={`${dist.intermediate} Fortgeschrittene`}>
<span className="text-white text-xs font-bold">{dist.intermediate}</span>
</div>
)}
{dist.expert > 0 && (
<div className="relative flex items-center justify-center" title={`${dist.expert} Experten`}>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
fill="rgb(147 51 234)"
/>
</svg>
<span className="absolute text-white text-xs font-bold" style={{ fontSize: '10px' }}>{dist.expert}</span>
</div>
)}
</div>
<span className="text-xs text-tertiary ml-auto">
{skill.userCount} total
</span>
</div>
</button>
)
})}
</div> </div>
) : searchResults.length === 0 ? ( )}
<div className="text-center py-12"> </div>
<p className="text-tertiary"> </div>
{/* Right column - Employees */}
<div className="lg:col-span-1">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Mitarbeiter
{filteredEmployees.length > 0 && (
<span className="ml-2 text-body font-normal text-tertiary">
({filteredEmployees.length})
</span>
)}
</h2>
{selectedSkills.size === 0 && freeSearchTerm.length === 0 ? (
<div className="text-center py-8">
<p className="text-tertiary text-sm">
Wählen Sie einen Skill aus
</p>
</div>
) : filteredEmployees.length === 0 ? (
<div className="text-center py-8">
<p className="text-tertiary text-sm">
Keine Mitarbeiter gefunden Keine Mitarbeiter gefunden
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4"> <div className="space-y-2 max-h-[600px] overflow-y-auto">
{searchResults.map((employee) => ( {filteredEmployees.map((employee: any) => {
<EmployeeCard const skillLevel = parseInt(employee.selectedSkillLevel) || 0
key={employee.id} return (
employee={employee} <button
onClick={() => navigate(`/employees/${employee.id}`)} key={employee.id}
/> onClick={() => navigate(`/employees/${employee.id}`)}
))} className="w-full p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-left"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-primary">
{employee.firstName} {employee.lastName}
</div>
<div className="text-xs text-tertiary">
{employee.position}
</div>
</div>
{selectedSkills.size > 0 && (
<div className="flex items-center gap-1">
{skillLevel >= 7 ? (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" title={`Experte (Level ${skillLevel})`}>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z"
fill="rgb(147 51 234)"
/>
</svg>
) : skillLevel >= 4 ? (
<div className="w-5 h-5 bg-green-500 flex items-center justify-center" title={`Fortgeschritten (Level ${skillLevel})`}>
</div>
) : skillLevel >= 1 ? (
<div className="w-5 h-5 bg-red-500 rounded-full flex items-center justify-center" title={`Anfänger (Level ${skillLevel})`}>
</div>
) : null}
</div>
)}
</div>
</button>
)
})}
</div> </div>
)} )}
</div> </div>

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

22
gitea_push_debug.txt Normale Datei
Datei anzeigen

@ -0,0 +1,22 @@
Push Debug Info - 2025-09-20 21:31:04.549482
Repository: SkillMate
Owner: IntelSight
Path: A:\GiTea\SkillMate
Current branch: master
Git remotes:
origin https://StuXn3t:29aa2ffb5ef85bd4f56e2e7bd19098310a37f3bd@gitea-undso.intelsight.de/IntelSight/SkillMate.git (fetch)
origin https://StuXn3t:29aa2ffb5ef85bd4f56e2e7bd19098310a37f3bd@gitea-undso.intelsight.de/IntelSight/SkillMate.git (push)
Git status before push:
Clean
Push command: git push --set-upstream origin master:main -v
Push result: Success
Push stdout:
branch 'master' set up to track 'origin/main'.
Push stderr:
POST git-receive-pack (17429449 bytes)
remote: . Processing 1 references
remote: Processed 1 references in total
Pushing to https://gitea-undso.intelsight.de/IntelSight/SkillMate.git
To https://gitea-undso.intelsight.de/IntelSight/SkillMate.git
* [new branch] master -> main
updating local tracking ref 'refs/remotes/origin/main'

6
package-lock.json generiert Normale Datei
Datei anzeigen

@ -0,0 +1,6 @@
{
"name": "SkillMate",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}