So mit neuen UI Ideen und so
Dieser Commit ist enthalten in:
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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-Diff unterdrückt, da er zu groß ist
Diff laden
1286
backend/package-lock.json
generiert
1286
backend/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
43
backend/scripts/migrations/0001_users_email_encrypt.js
Normale Datei
43
backend/scripts/migrations/0001_users_email_encrypt.js
Normale Datei
@ -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 {}
|
||||||
|
}
|
||||||
|
|
||||||
54
backend/scripts/run-migrations.js
Normale Datei
54
backend/scripts/run-migrations.js
Normale Datei
@ -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()
|
||||||
|
|
||||||
43
backend/src/config/appConfig.ts
Normale Datei
43
backend/src/config/appConfig.ts
Normale Datei
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
317
backend/src/repositories/employeeRepository.ts
Normale Datei
317
backend/src/repositories/employeeRepository.ts
Normale Datei
@ -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()
|
||||||
|
}
|
||||||
35
backend/src/services/auditService.ts
Normale Datei
35
backend/src/services/auditService.ts
Normale Datei
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
123
backend/src/services/sync/applier.ts
Normale Datei
123
backend/src/services/sync/applier.ts
Normale Datei
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
60
backend/src/services/sync/queueStore.ts
Normale Datei
60
backend/src/services/sync/queueStore.ts
Normale Datei
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
backend/src/services/sync/transport.ts
Normale Datei
17
backend/src/services/sync/transport.ts
Normale Datei
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
69
backend/src/usecases/auth/loginUser.ts
Normale Datei
69
backend/src/usecases/auth/loginUser.ts
Normale Datei
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
109
backend/src/usecases/employees.ts
Normale Datei
109
backend/src/usecases/employees.ts
Normale Datei
@ -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(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
48
backend/src/usecases/users.ts
Normale Datei
48
backend/src/usecases/users.ts
Normale Datei
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
23
backend/src/validation/employeeValidators.ts
Normale Datei
23
backend/src/validation/employeeValidators.ts
Normale Datei
@ -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
29
docs/ARCHITECTURE.md
Normale Datei
@ -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
81
docs/REFAKTOR_PLAN.txt
Normale Datei
@ -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
32
docs/SMOKE_TESTS.md
Normale Datei
@ -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.
|
||||||
|
|
||||||
753
frontend/package-lock.json
generiert
753
frontend/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -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": {
|
||||||
|
|||||||
30
frontend/src/components/ErrorBoundary.tsx
Normale Datei
30
frontend/src/components/ErrorBoundary.tsx
Normale Datei
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
565
frontend/src/components/OfficeMap3D.tsx
Normale Datei
565
frontend/src/components/OfficeMap3D.tsx
Normale Datei
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/components/OfficeMapModal.tsx
Normale Datei
98
frontend/src/components/OfficeMapModal.tsx
Normale Datei
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 { employeeApi } from '../services/api'
|
import OfficeMapModal from '../components/OfficeMapModal'
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[] = []
|
||||||
|
|
||||||
|
// Search in skills
|
||||||
|
allSkillsWithStats.forEach(skill => {
|
||||||
|
if (skill.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||||
|
suggestions.push(skill.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search in categories
|
||||||
|
hierarchy.forEach(cat => {
|
||||||
|
if (cat.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||||
|
suggestions.push(cat.name)
|
||||||
|
}
|
||||||
|
cat.subcategories?.forEach(sub => {
|
||||||
|
if (sub.name.toLowerCase().includes(freeSearchTerm.toLowerCase())) {
|
||||||
|
suggestions.push(sub.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setSearchSuggestions(suggestions.slice(0, 5))
|
||||||
|
} else {
|
||||||
|
setSearchSuggestions([])
|
||||||
|
}
|
||||||
|
}, [freeSearchTerm, allSkillsWithStats, hierarchy])
|
||||||
|
|
||||||
|
const toggleCategory = (categoryId: string) => {
|
||||||
|
const newSelection = new Set(selectedCategories)
|
||||||
|
if (newSelection.has(categoryId)) {
|
||||||
|
newSelection.delete(categoryId)
|
||||||
|
if (newSelection.size === 0) {
|
||||||
|
setSelectedSkills(new Set())
|
||||||
|
setFilteredEmployees([])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newSelection.add(categoryId)
|
||||||
|
}
|
||||||
|
setSelectedCategories(newSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubCategoryChange = (subCategoryId: string) => {
|
// Toggle skill selection and filter employees
|
||||||
setSelectedSubCategory(subCategoryId)
|
const handleSkillClick = (skillId: string) => {
|
||||||
setSelectedSkills([])
|
const newSelection = new Set(selectedSkills)
|
||||||
}
|
|
||||||
|
|
||||||
const handleSkillToggle = (skillId: string) => {
|
|
||||||
setSelectedSkills(prev =>
|
|
||||||
prev.includes(skillId)
|
|
||||||
? prev.filter(s => s !== skillId)
|
|
||||||
: [...prev, skillId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setHasSearched(true)
|
|
||||||
|
|
||||||
try {
|
if (newSelection.has(skillId)) {
|
||||||
// Get all employees first
|
newSelection.delete(skillId)
|
||||||
const allEmployees = await employeeApi.getAll()
|
} 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
|
||||||
|
})
|
||||||
|
|
||||||
// Filter based on search criteria
|
setFilteredEmployees(filtered)
|
||||||
let results = [...allEmployees]
|
|
||||||
|
|
||||||
// Filter by free search term
|
|
||||||
if (freeSearchTerm) {
|
|
||||||
const searchLower = freeSearchTerm.toLowerCase()
|
|
||||||
results = results.filter(employee => {
|
|
||||||
// Search in name, department, position
|
|
||||||
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
|
|
||||||
employee.department.toLowerCase().includes(searchLower) ||
|
|
||||||
employee.position.toLowerCase().includes(searchLower)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search in skills
|
|
||||||
if (employee.skills?.some((skill: any) => skill.name.toLowerCase().includes(searchLower))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search in languages
|
|
||||||
if (employee.languages?.some((lang: any) => {
|
|
||||||
if (!lang) return false
|
|
||||||
return lang.language.toLowerCase().includes(searchLower)
|
|
||||||
})) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search in specializations
|
|
||||||
if (employee.specializations?.some((spec: any) => spec.toLowerCase().includes(searchLower))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by selected skills (IDs)
|
|
||||||
if (selectedSkills.length > 0) {
|
|
||||||
results = results.filter(employee => {
|
|
||||||
return selectedSkills.some(skillId => {
|
|
||||||
// Check in skills array by ID
|
|
||||||
if (employee.skills?.some((skill: any) => skill.id === skillId)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Ignore languages/specializations here (catalog-driven search)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchResults(results)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search failed:', error)
|
|
||||||
setSearchResults([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
{/* Skill-Auswahl */}
|
}`}
|
||||||
{selectedSubCategory && (
|
>
|
||||||
<div>
|
<div className="flex items-start justify-between mb-2">
|
||||||
<label className="block text-body font-medium text-secondary mb-2">
|
<div>
|
||||||
Skills auswählen
|
<span className="font-medium text-primary">{skill.name}</span>
|
||||||
</label>
|
<span className="ml-2 text-xs text-tertiary">({skill.subcategory})</span>
|
||||||
<div className="space-y-2 max-h-64 overflow-y-auto border border-border-default rounded-card p-3">
|
</div>
|
||||||
{getSkills().map((skill) => (
|
{isSelected && <span className="text-blue-600 text-lg">✓</span>}
|
||||||
<label key={skill.id} className="flex items-center space-x-2 cursor-pointer hover:bg-bg-accent p-1 rounded">
|
</div>
|
||||||
<input
|
|
||||||
type="checkbox"
|
{/* Level-Verteilung mit geometrischen Formen */}
|
||||||
checked={selectedSkills.includes(skill.id)}
|
<div className="flex items-center gap-3">
|
||||||
onChange={() => handleSkillToggle(skill.id)}
|
<div className="flex gap-2">
|
||||||
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
|
{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-body text-secondary">{skill.name}</span>
|
<span className="text-white text-xs font-bold">{dist.beginner}</span>
|
||||||
</label>
|
</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} 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
22
gitea_push_debug.txt
Normale Datei
@ -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
6
package-lock.json
generiert
Normale Datei
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "SkillMate",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren