>((acc, def) => {
+ acc[def.id] = def
+ return acc
+ }, {})
+ }, [])
+
+ const getPowerFunctionLabel = (id?: string | null) => {
+ if (!id) return undefined
+ return powerFunctionLabelMap[id]?.label || undefined
+ }
+
// removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => {
+ setRoleValidationError('')
+
+ if (editRole === 'superuser') {
+ if (!editPowerUnitId) {
+ setRoleValidationError('Bitte wählen Sie eine Organisationseinheit für den Poweruser aus.')
+ return
+ }
+ if (!editPowerFunction) {
+ setRoleValidationError('Bitte wählen Sie eine Poweruser-Funktion aus.')
+ return
+ }
+
+ const def = POWER_FUNCTIONS.find(fn => fn.id === editPowerFunction)
+ if (def && editPowerUnitType && !def.unitTypes.includes(editPowerUnitType)) {
+ setRoleValidationError(`Die Funktion ${def.label} ist für den gewählten Einheitstyp nicht zulässig.`)
+ return
+ }
+ }
+
try {
- await api.put(`/admin/users/${userId}/role`, { role: editRole })
+ await api.put(`/admin/users/${userId}/role`, {
+ role: editRole,
+ powerUnitId: editRole === 'superuser' ? editPowerUnitId : null,
+ powerFunction: editRole === 'superuser' ? editPowerFunction : null
+ })
await fetchUsers()
setEditingUser(null)
+ setEditPowerUnitId(null)
+ setEditPowerUnitName('')
+ setEditPowerUnitType(null)
+ setEditPowerFunction('')
} catch (err: any) {
setError('Rolle konnte nicht geändert werden')
}
@@ -339,35 +400,132 @@ export default function UserManagement() {
Nicht verknüpft
)}
- |
+ |
{editingUser === user.id ? (
-
-
-
-
+
+
+
+
+
+
+
+ {editRole === 'superuser' && (
+
+
+
+ {
+ setEditPowerUnitId(unitId)
+ setEditPowerUnitName(details?.displayPath || formattedValue)
+ setEditPowerUnitType(details?.unit.type || null)
+ setRoleValidationError('')
+ if (!unitId) {
+ setEditPowerFunction('')
+ }
+ }}
+ />
+
+ {editPowerUnitName || 'Keine Einheit ausgewählt'}
+
+
+
+
+
+
+ {!editPowerUnitId && (
+
+ Bitte wählen Sie zuerst eine Organisationseinheit aus.
+
+ )}
+ {editPowerUnitId && availableEditFunctions.length === 0 && (
+
+ Für den gewählten Einheitstyp sind keine Poweruser-Funktionen definiert.
+
+ )}
+ {selectedEditPowerFunction && !selectedEditPowerFunction.canManageEmployees && (
+
+ Hinweis: Diese Funktion berechtigt nicht zum Anlegen neuer Mitarbeitender.
+
+ )}
+
+
+ {roleValidationError && (
+ {roleValidationError}
+ )}
+
+ )}
) : (
-
- {getRoleLabel(user.role)}
-
+
+
+ {getRoleLabel(user.role)}
+
+ {user.role === 'superuser' && (
+
+ {user.powerUnitName ? (
+ {user.powerUnitName}
+ ) : (
+ Keine Einheit zugewiesen
+ )}
+
+ {user.powerFunction ? getPowerFunctionLabel(user.powerFunction) || 'Keine Funktion' : 'Keine Funktion'}
+ {user.powerFunction && (user.canManageEmployees === false || powerFunctionLabelMap[user.powerFunction]?.canManageEmployees === false) && (
+ (keine Mitarbeitendenanlage)
+ )}
+
+
+ )}
+
)}
|
@@ -389,6 +547,11 @@ export default function UserManagement() {
onClick={() => {
setEditingUser(user.id)
setEditRole(user.role)
+ setEditPowerUnitId(user.powerUnitId || null)
+ setEditPowerUnitName(user.powerUnitName || '')
+ setEditPowerUnitType(user.powerUnitType || null)
+ setEditPowerFunction(user.powerFunction || '')
+ setRoleValidationError('')
}}
className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten"
@@ -483,7 +646,7 @@ export default function UserManagement() {
- • Administrator: Vollzugriff auf alle Funktionen und Einstellungen
- - • Poweruser: Kann Mitarbeitende und Skills verwalten, aber keine Systemeinstellungen ändern
+ - • Poweruser: Kann Mitarbeitende und die Skillverwaltung nutzen, aber keine Systemeinstellungen ändern
- • Benutzer: Kann nur eigenes Profil bearbeiten und Daten einsehen
- • Neue Benutzer können über den Import oder die Mitarbeitendenverwaltung angelegt werden
- • Der Admin-Benutzer kann nicht gelöscht werden
diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts
index a199c95..df150b4 100644
--- a/admin-panel/vite.config.ts
+++ b/admin-panel/vite.config.ts
@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
+ // Use relative asset paths so the build works when deployed under a subdirectory
+ base: './',
plugins: [react()],
server: {
port: Number(process.env.VITE_PORT || 5174),
diff --git a/backend/scripts/seed-skills-from-frontend.js b/backend/scripts/seed-skills-from-frontend.js
index fbd2e61..a10fc5a 100644
--- a/backend/scripts/seed-skills-from-frontend.js
+++ b/backend/scripts/seed-skills-from-frontend.js
@@ -3,32 +3,43 @@
const fs = require('fs')
const path = require('path')
-const vm = require('vm')
const Database = require('better-sqlite3')
-function parseFrontendHierarchy() {
+function loadHierarchy() {
+ const sharedPath = path.join(process.cwd(), '..', 'shared', 'skills.js')
+ if (fs.existsSync(sharedPath)) {
+ const sharedModule = require(sharedPath)
+ if (Array.isArray(sharedModule?.SKILL_HIERARCHY)) {
+ return sharedModule.SKILL_HIERARCHY
+ }
+ throw new Error('SKILL_HIERARCHY missing or invalid in shared/skills.js')
+ }
+
const tsPath = path.join(process.cwd(), '..', 'frontend', 'src', 'data', 'skillCategories.ts')
+ if (!fs.existsSync(tsPath)) {
+ throw new Error('No skill hierarchy definition found in shared/skills.js or frontend/src/data/skillCategories.ts')
+ }
+
const src = fs.readFileSync(tsPath, 'utf8')
- // Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
let code = src
.replace(/export interface[\s\S]*?\n\}/g, '')
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
const sandbox = { module: {}, exports: {} }
- vm.createContext(sandbox)
- vm.runInContext(code, sandbox)
- return sandbox.module.exports || sandbox.exports
+ require('vm').runInNewContext(code, sandbox)
+ const hierarchy = sandbox.module?.exports || sandbox.exports
+ if (!Array.isArray(hierarchy)) {
+ throw new Error('Parsed hierarchy is not an array')
+ }
+ return hierarchy
}
function main() {
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath)
try {
- const hierarchy = parseFrontendHierarchy()
- if (!Array.isArray(hierarchy)) {
- throw new Error('Parsed hierarchy is not an array')
- }
+ const hierarchy = loadHierarchy()
const insert = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, expires_after)
diff --git a/backend/src/config/secureDatabase.ts b/backend/src/config/secureDatabase.ts
index 5534712..0c12061 100644
--- a/backend/src/config/secureDatabase.ts
+++ b/backend/src/config/secureDatabase.ts
@@ -207,6 +207,86 @@ export function initializeSecureDatabase() {
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
`)
+ // Official titles catalog managed via admin panel
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS official_titles (
+ id TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ order_index INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_official_titles_label ON official_titles(label COLLATE NOCASE);
+ CREATE INDEX IF NOT EXISTS idx_official_titles_order ON official_titles(order_index);
+ `)
+
+ try {
+ const existingTitles = db.prepare('SELECT COUNT(*) as count FROM official_titles').get() as { count: number }
+ if (!existingTitles || existingTitles.count === 0) {
+ const defaults = [
+ 'Sachbearbeitung',
+ 'stellvertretende Sachgebietsleitung',
+ 'Sachgebietsleitung',
+ 'Dezernatsleitung',
+ 'Abteilungsleitung',
+ 'Behördenleitung'
+ ]
+ const now = new Date().toISOString()
+ const insert = db.prepare(`
+ INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, 1, ?, ?)
+ `)
+ defaults.forEach((label, index) => {
+ insert.run(uuidv4(), label, index, now, now)
+ })
+ }
+ } catch (error) {
+ console.warn('Failed to seed official titles:', error)
+ }
+
+ // Position catalog managed via admin panel (optionally scoped per organisationseinheit)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS position_catalog (
+ id TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ organization_unit_id TEXT,
+ order_index INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_position_catalog_unit ON position_catalog(organization_unit_id);
+ CREATE INDEX IF NOT EXISTS idx_position_catalog_order ON position_catalog(order_index);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_position_catalog_unique ON position_catalog(label COLLATE NOCASE, IFNULL(organization_unit_id, 'GLOBAL'));
+ `)
+
+ try {
+ const existingPositions = db.prepare('SELECT COUNT(*) as count FROM position_catalog WHERE organization_unit_id IS NULL').get() as { count: number }
+ if (!existingPositions || existingPositions.count === 0) {
+ const defaults = [
+ 'Sachbearbeitung',
+ 'stellvertretende Sachgebietsleitung',
+ 'Sachgebietsleitung',
+ 'Dezernatsleitung',
+ 'Abteilungsleitung',
+ 'Behördenleitung'
+ ]
+ const now = new Date().toISOString()
+ const insert = db.prepare(`
+ INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
+ VALUES (?, ?, NULL, ?, 1, ?, ?)
+ `)
+ defaults.forEach((label, index) => {
+ insert.run(uuidv4(), label, index, now, now)
+ })
+ }
+ } catch (error) {
+ console.warn('Failed to seed position catalog:', error)
+ }
+
// Users table with encrypted email
db.exec(`
CREATE TABLE IF NOT EXISTS users (
@@ -217,19 +297,37 @@ export function initializeSecureDatabase() {
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
+ power_unit_id TEXT,
+ power_function TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
- UNIQUE(email_hash)
+ UNIQUE(email_hash),
+ FOREIGN KEY(power_unit_id) REFERENCES organizational_units(id)
)
`)
-
+
// Create index for email hash
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
`)
+ // Ensure new power user columns exist (legacy migrations)
+ try {
+ const userCols: any[] = db.prepare(`PRAGMA table_info(users)`).all() as any
+ const hasPowerUnit = userCols.some(c => c.name === 'power_unit_id')
+ const hasPowerFunction = userCols.some(c => c.name === 'power_function')
+ if (!hasPowerUnit) {
+ db.exec(`ALTER TABLE users ADD COLUMN power_unit_id TEXT`)
+ }
+ if (!hasPowerFunction) {
+ db.exec(`ALTER TABLE users ADD COLUMN power_function TEXT`)
+ }
+ } catch (error) {
+ console.error('Failed to ensure power user columns:', error)
+ }
+
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 06714a6..0e2b24a 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -16,6 +16,8 @@ import workspaceRoutes from './routes/workspaces'
import userRoutes from './routes/users'
import userAdminRoutes from './routes/usersAdmin'
import settingsRoutes from './routes/settings'
+import officialTitlesRoutes from './routes/officialTitles'
+import positionsRoutes from './routes/positions'
import organizationRoutes from './routes/organization'
import organizationImportRoutes from './routes/organizationImport'
import employeeOrganizationRoutes from './routes/employeeOrganization'
@@ -65,6 +67,8 @@ app.use('/api/workspaces', workspaceRoutes)
app.use('/api/users', userRoutes)
app.use('/api/admin/users', userAdminRoutes)
app.use('/api/admin/settings', settingsRoutes)
+app.use('/api/positions', positionsRoutes)
+app.use('/api/official-titles', officialTitlesRoutes)
app.use('/api/organization', organizationRoutes)
app.use('/api/organization', organizationImportRoutes)
app.use('/api', employeeOrganizationRoutes)
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index 1d7931e..8b5b166 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { body, validationResult } from 'express-validator'
-import { db } from '../config/secureDatabase'
-import { User, LoginRequest, LoginResponse } from '@skillmate/shared'
+import { db, encryptedDb } from '../config/secureDatabase'
+import { User, LoginRequest, LoginResponse, POWER_FUNCTIONS } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { logger } from '../utils/logger'
+import { emailService } from '../services/emailService'
const router = Router()
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
@@ -77,7 +78,20 @@ router.post('/login',
const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
+ // Enrich with power user meta (unit + function)
+ const power = db.prepare(`
+ SELECT u.power_unit_id as powerUnitId,
+ u.power_function as powerFunction,
+ ou.name as powerUnitName,
+ ou.type as powerUnitType
+ FROM users u
+ LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
+ WHERE u.id = ?
+ `).get(userRow.id) as any
+
// Create user object without password (decrypt email)
+ const powerFunctionId = power?.powerFunction || null
+ const powerDefinition = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
const user: User = {
id: userRow.id,
username: userRow.username,
@@ -87,7 +101,12 @@ router.post('/login',
lastLogin: new Date(now),
isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at),
- updatedAt: new Date(userRow.updated_at)
+ updatedAt: new Date(userRow.updated_at),
+ powerUnitId: power?.powerUnitId || null,
+ powerUnitName: power?.powerUnitName || null,
+ powerUnitType: power?.powerUnitType || null,
+ powerFunction: powerFunctionId,
+ canManageEmployees: userRow.role === 'admin' || (userRow.role === 'superuser' && Boolean(powerDefinition?.canManageEmployees))
}
// Generate token
@@ -114,8 +133,87 @@ router.post('/login',
}
)
+router.post('/forgot-password',
+ [
+ body('email').isEmail().normalizeEmail()
+ ],
+ async (req: Request, res: Response, next: NextFunction) => {
+ const genericMessage = 'Falls die angegebene E-Mail im System hinterlegt ist, erhalten Sie in Kürze ein neues Passwort.'
+
+ try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ // Immer generische Antwort senden, um keine Information preiszugeben
+ logger.warn('Forgot password request received with invalid email input')
+ return res.json({ success: true, message: genericMessage })
+ }
+
+ const { email } = req.body as { email: string }
+ const normalizedEmail = email.trim().toLowerCase()
+ const emailHash = FieldEncryption.hash(normalizedEmail)
+
+ const userRow = db.prepare(`
+ SELECT id, username, email, employee_id, is_active
+ FROM users
+ WHERE email_hash = ?
+ `).get(emailHash) as any
+
+ if (!userRow || !userRow.is_active) {
+ logger.info('Forgot password request for non-existing or inactive account')
+ return res.json({ success: true, message: genericMessage })
+ }
+
+ const decryptedEmail = FieldEncryption.decrypt(userRow.email) || normalizedEmail
+
+ let firstName: string | undefined
+ if (userRow.employee_id) {
+ try {
+ const employee = encryptedDb.getEmployee(userRow.employee_id)
+ if (employee?.first_name) {
+ firstName = employee.first_name
+ }
+ } catch (error) {
+ logger.warn(`Failed to resolve employee for password reset: ${error}`)
+ }
+ }
+
+ const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
+ const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
+ const canSendEmail = emailNotificationsEnabled && emailService.isServiceEnabled()
+
+ if (!canSendEmail) {
+ logger.warn('Password reset requested but email notifications are disabled or email service unavailable')
+ return res.json({ success: true, message: genericMessage })
+ }
+
+ const temporaryPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
+ const sent = await emailService.sendInitialPassword(decryptedEmail, temporaryPassword, firstName)
+
+ if (!sent) {
+ logger.warn(`Password reset email could not be sent to ${decryptedEmail}`)
+ return res.json({ success: true, message: genericMessage })
+ }
+
+ const hashedPassword = await bcrypt.hash(temporaryPassword, 12)
+ const now = new Date().toISOString()
+
+ db.prepare(`
+ UPDATE users
+ SET password = ?, updated_at = ?
+ WHERE id = ?
+ `).run(hashedPassword, now, userRow.id)
+
+ logger.info(`Password reset processed for user ${userRow.username}`)
+ return res.json({ success: true, message: genericMessage })
+ } catch (error) {
+ logger.error('Error processing forgot password request:', error)
+ return res.json({ success: true, message: genericMessage })
+ }
+ }
+)
+
router.post('/logout', (req, res) => {
res.json({ success: true, message: 'Logged out successfully' })
})
-export default router
\ No newline at end of file
+export default router
diff --git a/backend/src/routes/employeeOrganization.ts b/backend/src/routes/employeeOrganization.ts
index 7bfcc44..5b2809c 100644
--- a/backend/src/routes/employeeOrganization.ts
+++ b/backend/src/routes/employeeOrganization.ts
@@ -82,17 +82,24 @@ router.put('/employee/:employeeId/organization', authenticate, async (req: AuthR
assignmentId, employeeId, unitId, 'mitarbeiter',
now, 1, now, now
)
+
+ // Keep employees.primary_unit_id in sync for listings
+ db.prepare(`
+ UPDATE employees
+ SET primary_unit_id = ?, updated_at = ?
+ WHERE id = ?
+ `).run(unitId, now, employeeId)
}
// Update employee's department field for backward compatibility
if (unitId) {
- const unitInfo = db.prepare('SELECT name FROM organizational_units WHERE id = ?').get(unitId) as any
+ const unitInfo = db.prepare('SELECT code, name FROM organizational_units WHERE id = ?').get(unitId) as any
if (unitInfo) {
db.prepare(`
UPDATE employees
SET department = ?, updated_at = ?
WHERE id = ?
- `).run(unitInfo.name, now, employeeId)
+ `).run(unitInfo.code || unitInfo.name, now, employeeId)
}
} else {
// Clear department if no unit
@@ -163,4 +170,4 @@ router.get('/unit/:unitId/employees', authenticate, async (req: AuthRequest, res
}
})
-export default router
\ No newline at end of file
+export default router
diff --git a/backend/src/routes/employees.ts b/backend/src/routes/employees.ts
index 38ac6e1..115f2aa 100644
--- a/backend/src/routes/employees.ts
+++ b/backend/src/routes/employees.ts
@@ -5,12 +5,99 @@ import bcrypt from 'bcrypt'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
-import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole } from '@skillmate/shared'
+import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
+import { decodeHtmlEntities } from '../utils/html'
+import { createDepartmentResolver } from '../utils/department'
const router = Router()
+function toSqlDateTime(date: Date): string {
+ const pad = (value: number) => value.toString().padStart(2, '0')
+ const year = date.getUTCFullYear()
+ const month = pad(date.getUTCMonth() + 1)
+ const day = pad(date.getUTCDate())
+ const hours = pad(date.getUTCHours())
+ const minutes = pad(date.getUTCMinutes())
+ const seconds = pad(date.getUTCSeconds())
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+function clearActiveDeputiesForPrincipal(principalId: string) {
+ const now = new Date()
+ const past = new Date(now.getTime() - 1000)
+ const pastSql = toSqlDateTime(past)
+ const nowSql = toSqlDateTime(now)
+ db.prepare(`
+ UPDATE deputy_assignments
+ SET valid_until = ?, updated_at = ?
+ WHERE principal_id = ?
+ AND valid_until >= datetime('now')
+ `).run(pastSql, nowSql, principalId)
+ db.prepare(`
+ DELETE FROM deputy_assignments
+ WHERE principal_id = ?
+ AND valid_from > datetime('now')
+ `).run(principalId)
+}
+
+const resolveDepartmentInfo = createDepartmentResolver(db)
+
+function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
+ const rows = db.prepare(`
+ SELECT
+ da.id as assignmentId,
+ da.deputy_id as deputyId,
+ e.first_name as firstName,
+ e.last_name as lastName,
+ e.availability as availability,
+ e.position as position
+ FROM deputy_assignments da
+ JOIN employees e ON e.id = da.deputy_id
+ WHERE da.principal_id = ?
+ AND da.valid_from <= datetime('now')
+ AND da.valid_until >= datetime('now')
+ ORDER BY e.last_name, e.first_name
+ `).all(principalId) as any[]
+
+ return rows.map(row => ({
+ id: row.deputyId,
+ assignmentId: row.assignmentId,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ availability: row.availability,
+ position: row.position || undefined
+ }))
+}
+
+function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
+ const rows = db.prepare(`
+ SELECT
+ da.id as assignmentId,
+ da.principal_id as principalId,
+ e.first_name as firstName,
+ e.last_name as lastName,
+ e.availability as availability,
+ e.position as position
+ FROM deputy_assignments da
+ JOIN employees e ON e.id = da.principal_id
+ WHERE da.deputy_id = ?
+ AND da.valid_from <= datetime('now')
+ AND da.valid_until >= datetime('now')
+ ORDER BY e.last_name, e.first_name
+ `).all(deputyId) as any[]
+
+ return rows.map(row => ({
+ id: row.principalId,
+ assignmentId: row.assignmentId,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ availability: row.availability,
+ position: row.position || undefined
+ }))
+}
+
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
const mapping: Record = {
@@ -34,15 +121,65 @@ function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'nativ
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const employees = db.prepare(`
- SELECT id, first_name, last_name, employee_number, photo, position, official_title,
- department, email, phone, mobile, office, availability,
- clearance_level, clearance_valid_until, clearance_issued_date,
- created_at, updated_at, created_by, updated_by
- FROM employees
- ORDER BY last_name, first_name
+ SELECT
+ e.id,
+ e.first_name,
+ e.last_name,
+ e.employee_number,
+ e.photo,
+ e.position,
+ e.official_title,
+ e.department,
+ e.email,
+ e.phone,
+ e.mobile,
+ e.office,
+ e.availability,
+ e.primary_unit_id as primaryUnitId,
+ ou.code as primaryUnitCode,
+ ou.name as primaryUnitName,
+ ou.description as primaryUnitDescription,
+ e.clearance_level,
+ e.clearance_valid_until,
+ e.clearance_issued_date,
+ e.created_at,
+ e.updated_at,
+ e.created_by,
+ e.updated_by
+ FROM employees e
+ LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
+ ORDER BY e.last_name, e.first_name
`).all()
const employeesWithDetails = employees.map((emp: any) => {
+ const decodeValue = (val: string | null) => {
+ if (val === null || val === undefined) return undefined
+ return decodeHtmlEntities(val) ?? val
+ }
+
+ const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
+ const decodedLastName = decodeValue(emp.last_name) || emp.last_name
+ const decodedPosition = decodeValue(emp.position) || emp.position
+ const decodedOfficialTitle = decodeValue(emp.official_title)
+ const decodedDepartmentRaw = decodeValue(emp.department)
+ const decodedEmail = decodeValue(emp.email) || emp.email
+ const decodedPhone = decodeValue(emp.phone) || emp.phone
+ const decodedMobile = decodeValue(emp.mobile)
+ const decodedOffice = decodeValue(emp.office)
+ const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
+ const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
+ const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
+ const departmentInfo = resolveDepartmentInfo({
+ department: emp.department,
+ primaryUnitId: emp.primaryUnitId,
+ primaryUnitCode: emp.primaryUnitCode,
+ primaryUnitName: emp.primaryUnitName,
+ primaryUnitDescription: emp.primaryUnitDescription,
+ })
+ const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
+ const departmentDescription = departmentInfo.description
+ const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
+
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
@@ -65,17 +202,22 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
const employee: Employee = {
id: emp.id,
- firstName: emp.first_name,
- lastName: emp.last_name,
+ firstName: decodedFirstName,
+ lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
- position: emp.position,
- officialTitle: emp.official_title || undefined,
- department: emp.department,
- email: emp.email,
- phone: emp.phone,
- mobile: emp.mobile,
- office: emp.office,
+ position: decodedPosition,
+ officialTitle: decodedOfficialTitle,
+ department: departmentLabel,
+ departmentDescription,
+ departmentTasks,
+ primaryUnitId: emp.primaryUnitId || undefined,
+ primaryUnitCode: decodedPrimaryUnitCode,
+ primaryUnitName: decodedPrimaryUnitName,
+ email: decodedEmail,
+ phone: decodedPhone,
+ mobile: decodedMobile,
+ office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@@ -99,7 +241,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
- updatedBy: emp.updated_by
+ updatedBy: emp.updated_by,
+ currentDeputies: getActiveDeputies(emp.id),
+ represents: getActivePrincipals(emp.id)
}
return employee
@@ -117,12 +261,34 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
const { id } = req.params
const emp = db.prepare(`
- SELECT id, first_name, last_name, employee_number, photo, position, official_title,
- department, email, phone, mobile, office, availability,
- clearance_level, clearance_valid_until, clearance_issued_date,
- created_at, updated_at, created_by, updated_by
- FROM employees
- WHERE id = ?
+ SELECT
+ e.id,
+ e.first_name,
+ e.last_name,
+ e.employee_number,
+ e.photo,
+ e.position,
+ e.official_title,
+ e.department,
+ e.email,
+ e.phone,
+ e.mobile,
+ e.office,
+ e.availability,
+ e.primary_unit_id as primaryUnitId,
+ ou.code as primaryUnitCode,
+ ou.name as primaryUnitName,
+ ou.description as primaryUnitDescription,
+ e.clearance_level,
+ e.clearance_valid_until,
+ e.clearance_issued_date,
+ e.created_at,
+ e.updated_at,
+ e.created_by,
+ e.updated_by
+ FROM employees e
+ LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
+ WHERE e.id = ?
`).get(id) as any
if (!emp) {
@@ -152,19 +318,52 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
+ const decodeValue = (val: string | null) => {
+ if (val === null || val === undefined) return undefined
+ return decodeHtmlEntities(val) ?? val
+ }
+
+ const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
+ const decodedLastName = decodeValue(emp.last_name) || emp.last_name
+ const decodedPosition = decodeValue(emp.position) || emp.position
+ const decodedOfficialTitle = decodeValue(emp.official_title)
+ const decodedDepartmentRaw = decodeValue(emp.department)
+ const decodedEmail = decodeValue(emp.email) || emp.email
+ const decodedPhone = decodeValue(emp.phone) || emp.phone
+ const decodedMobile = decodeValue(emp.mobile)
+ const decodedOffice = decodeValue(emp.office)
+ const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
+ const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
+ const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
+ const departmentInfo = resolveDepartmentInfo({
+ department: emp.department,
+ primaryUnitId: emp.primaryUnitId,
+ primaryUnitCode: emp.primaryUnitCode,
+ primaryUnitName: emp.primaryUnitName,
+ primaryUnitDescription: emp.primaryUnitDescription,
+ })
+ const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
+ const departmentDescription = departmentInfo.description
+ const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
+
const employee: Employee = {
id: emp.id,
- firstName: emp.first_name,
- lastName: emp.last_name,
+ firstName: decodedFirstName,
+ lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
- position: emp.position,
- officialTitle: emp.official_title || undefined,
- department: emp.department,
- email: emp.email,
- phone: emp.phone,
- mobile: emp.mobile,
- office: emp.office,
+ position: decodedPosition,
+ officialTitle: decodedOfficialTitle,
+ department: departmentLabel,
+ departmentDescription,
+ departmentTasks,
+ primaryUnitId: emp.primaryUnitId || undefined,
+ primaryUnitCode: decodedPrimaryUnitCode,
+ primaryUnitName: decodedPrimaryUnitName,
+ email: decodedEmail,
+ phone: decodedPhone,
+ mobile: decodedMobile,
+ office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@@ -188,7 +387,9 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
- updatedBy: emp.updated_by
+ updatedBy: emp.updated_by,
+ currentDeputies: getActiveDeputies(emp.id),
+ represents: getActivePrincipals(emp.id)
}
res.json({ success: true, data: employee })
@@ -204,11 +405,17 @@ router.post('/',
[
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
+ body('employeeNumber').optional({ checkFalsy: true }).trim(),
+ body('position').optional({ checkFalsy: true }).trim(),
body('officialTitle').optional().trim(),
body('email').isEmail(),
- body('department').notEmpty().trim(),
- body('organizationUnitId').optional({ checkFalsy: true }).isUUID(),
- body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
+ body('department').optional({ checkFalsy: true }).trim(),
+ body('phone').optional({ checkFalsy: true }).trim(),
+ body('mobile').optional({ checkFalsy: true }).trim(),
+ body('office').optional({ checkFalsy: true }).trim(),
+ body('organizationUnitId').notEmpty().isUUID(),
+ body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
+ body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -227,9 +434,16 @@ router.post('/',
firstName, lastName, employeeNumber, photo, position, officialTitle,
department, email, phone, mobile, office, availability,
clearance, skills, languages, specializations,
- userRole, createUser, organizationUnitId, organizationRole
+ userRole, createUser, organizationUnitId: organizationUnitIdRaw, organizationRole, powerFunction
} = req.body
+ const organizationUnitId = typeof organizationUnitIdRaw === 'string' ? organizationUnitIdRaw : ''
+ const normalizedDepartment = typeof department === 'string' ? department.trim() : ''
+
+ if (!organizationUnitId) {
+ return res.status(400).json({ success: false, error: { message: 'Organisatorische Einheit ist erforderlich' } })
+ }
+
if (organizationRole && !organizationUnitId) {
return res.status(400).json({
success: false,
@@ -237,20 +451,66 @@ router.post('/',
})
}
- let resolvedDepartment = department
+ const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
+ const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
+
+ if (userRole === 'superuser' && !requestedPowerFunction) {
+ return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
+ }
+
+ if (userRole === 'superuser' && !organizationUnitId && req.user?.role !== 'superuser') {
+ return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
+ }
+
+ let resolvedDepartment = normalizedDepartment
let resolvedUnitId: string | null = null
let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter'
if (organizationUnitId) {
- const unitRow = db.prepare('SELECT id, name FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; name: string } | undefined
+ const unitRow = db.prepare('SELECT id, code, name, type FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; code: string | null; name: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
}
resolvedUnitId = unitRow.id
- resolvedDepartment = unitRow.name
+ resolvedDepartment = unitRow.code || unitRow.name
if (organizationRole) {
resolvedUnitRole = organizationRole as EmployeeUnitRole
}
+
+ if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unitRow.type)) {
+ return res.status(400).json({
+ success: false,
+ error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unitRow.type} zugeordnet werden` }
+ })
+ }
+ }
+
+ if (req.user?.role === 'superuser') {
+ const canManage = req.user.canManageEmployees
+ if (!canManage) {
+ return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
+ }
+
+ if (!req.user.powerUnitId) {
+ return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
+ }
+
+ if (organizationUnitId && organizationUnitId !== req.user.powerUnitId) {
+ return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
+ }
+
+ const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
+ if (!unitRow) {
+ return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
+ }
+
+ resolvedUnitId = unitRow.id
+ resolvedDepartment = unitRow.code || unitRow.name
+ resolvedUnitRole = 'mitarbeiter'
+ }
+
+ if (!resolvedDepartment) {
+ resolvedDepartment = 'Noch nicht zugewiesen'
}
// Insert employee with default values for missing fields
@@ -373,12 +633,35 @@ router.post('/',
// Encrypt email for user table storage
const encryptedEmail = FieldEncryption.encrypt(email)
+ const emailHash = FieldEncryption.hash(email)
+
+ let powerUnitForUser: string | null = null
+ let powerFunctionForUser: string | null = null
+
+ if (userRole === 'superuser') {
+ if (!resolvedUnitId || !requestedPowerFunction) {
+ throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
+ }
+ powerUnitForUser = resolvedUnitId
+ powerFunctionForUser = requestedPowerFunction
+ }
db.prepare(`
- INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
- userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
+ userId,
+ email,
+ encryptedEmail,
+ emailHash,
+ hashedPassword,
+ userRole,
+ employeeId,
+ powerUnitForUser,
+ powerFunctionForUser,
+ 1,
+ now,
+ now
)
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
@@ -438,7 +721,7 @@ router.put('/:id',
body('department').notEmpty().trim(),
body('email').isEmail(),
body('phone').notEmpty().trim(),
- body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
+ body('availability').isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -459,7 +742,7 @@ router.put('/:id',
} = req.body
// Check if employee exists
- const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@@ -482,6 +765,10 @@ router.put('/:id',
now, req.user!.id, id
)
+ if (availability === 'available') {
+ clearActiveDeputiesForPrincipal(id)
+ }
+
// Update skills
if (skills !== undefined) {
// Delete existing skills
@@ -530,6 +817,10 @@ router.put('/:id',
await syncService.queueSync('employees', 'update', updatedEmployee)
+ if (availability === 'available') {
+ clearActiveDeputiesForPrincipal(id)
+ }
+
res.json({
success: true,
message: 'Employee updated successfully'
@@ -549,7 +840,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
- const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
diff --git a/backend/src/routes/employeesSecure.ts b/backend/src/routes/employeesSecure.ts
index 4dbcdb2..b8e6989 100644
--- a/backend/src/routes/employeesSecure.ts
+++ b/backend/src/routes/employeesSecure.ts
@@ -5,13 +5,99 @@ import bcrypt from 'bcrypt'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
-import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
+import { Employee, LanguageSkill, Skill, UserRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
+import { createDepartmentResolver } from '../utils/department'
+
const router = Router()
+const resolveDepartmentInfo = createDepartmentResolver(db)
+
+function toSqlDateTime(date: Date): string {
+ const pad = (value: number) => value.toString().padStart(2, '0')
+ const year = date.getUTCFullYear()
+ const month = pad(date.getUTCMonth() + 1)
+ const day = pad(date.getUTCDate())
+ const hours = pad(date.getUTCHours())
+ const minutes = pad(date.getUTCMinutes())
+ const seconds = pad(date.getUTCSeconds())
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+function clearActiveDeputiesForPrincipal(principalId: string) {
+ const now = new Date()
+ const past = new Date(now.getTime() - 1000)
+ const pastSql = toSqlDateTime(past)
+ const nowSql = toSqlDateTime(now)
+ db.prepare(`
+ UPDATE deputy_assignments
+ SET valid_until = ?, updated_at = ?
+ WHERE principal_id = ?
+ AND valid_until >= datetime('now')
+ `).run(pastSql, nowSql, principalId)
+ db.prepare(`
+ DELETE FROM deputy_assignments
+ WHERE principal_id = ?
+ AND valid_from > datetime('now')
+ `).run(principalId)
+}
+
+function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
+ const rows = db.prepare(`
+ SELECT
+ da.id as assignmentId,
+ da.deputy_id as deputyId,
+ e.first_name as firstName,
+ e.last_name as lastName,
+ e.availability as availability,
+ e.position as position
+ FROM deputy_assignments da
+ JOIN employees e ON e.id = da.deputy_id
+ WHERE da.principal_id = ?
+ AND da.valid_from <= datetime('now')
+ AND da.valid_until >= datetime('now')
+ ORDER BY e.last_name, e.first_name
+ `).all(principalId) as any[]
+
+ return rows.map(row => ({
+ id: row.deputyId,
+ assignmentId: row.assignmentId,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ availability: row.availability,
+ position: row.position || undefined
+ }))
+}
+
+function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
+ const rows = db.prepare(`
+ SELECT
+ da.id as assignmentId,
+ da.principal_id as principalId,
+ e.first_name as firstName,
+ e.last_name as lastName,
+ e.availability as availability,
+ e.position as position
+ FROM deputy_assignments da
+ JOIN employees e ON e.id = da.principal_id
+ WHERE da.deputy_id = ?
+ AND da.valid_from <= datetime('now')
+ AND da.valid_until >= datetime('now')
+ ORDER BY e.last_name, e.first_name
+ `).all(deputyId) as any[]
+
+ return rows.map(row => ({
+ id: row.principalId,
+ assignmentId: row.assignmentId,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ availability: row.availability,
+ position: row.position || undefined
+ }))
+}
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
@@ -114,6 +200,11 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
+ const departmentInfo = resolveDepartmentInfo({
+ department: emp.department,
+ primaryUnitId: emp.primary_unit_id,
+ })
+
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@@ -122,7 +213,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
- department: emp.department,
+ department: departmentInfo.label || emp.department,
+ departmentDescription: departmentInfo.description,
+ departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@@ -150,7 +243,10 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
- updatedBy: emp.updated_by
+ updatedBy: emp.updated_by,
+ primaryUnitId: emp.primary_unit_id || undefined,
+ currentDeputies: getActiveDeputies(emp.id),
+ represents: getActivePrincipals(emp.id)
}
return employee
@@ -197,6 +293,11 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
+ const departmentInfo = resolveDepartmentInfo({
+ department: emp.department,
+ primaryUnitId: emp.primary_unit_id,
+ })
+
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@@ -205,7 +306,9 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
- department: emp.department,
+ department: departmentInfo.label || emp.department,
+ departmentDescription: departmentInfo.description,
+ departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@@ -233,7 +336,10 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
- updatedBy: emp.updated_by
+ updatedBy: emp.updated_by,
+ primaryUnitId: emp.primary_unit_id || undefined,
+ currentDeputies: getActiveDeputies(emp.id),
+ represents: getActivePrincipals(emp.id)
}
return employee
@@ -334,13 +440,16 @@ router.post('/',
authenticate,
requirePermission('employees:create'),
[
- body('firstName').notEmpty().trim().escape(),
- body('lastName').notEmpty().trim().escape(),
+ body('firstName').notEmpty().trim(),
+ body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
- body('department').notEmpty().trim().escape(),
- body('position').optional().trim().escape(), // Optional
+ body('department').notEmpty().trim(),
+ body('position').optional().trim(), // Optional
body('phone').optional().trim(), // Optional - kann später ergänzt werden
- body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
+ body('employeeNumber').optional().trim(), // Optional - wird automatisch generiert wenn leer
+ body('primaryUnitId').optional({ checkFalsy: true }).isUUID(),
+ body('assignmentRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
+ body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@@ -358,10 +467,21 @@ router.post('/',
const {
firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle,
- department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
- clearance, skills = [], languages = [], specializations = [], userRole, createUser,
- primaryUnitId, assignmentRole
- } = req.body
+ department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
+ clearance, skills = [], languages = [], specializations = [], userRole, createUser,
+ primaryUnitId, assignmentRole, powerFunction
+ } = req.body
+
+ const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
+ const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
+
+ if (userRole === 'superuser' && !requestedPowerFunction) {
+ return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
+ }
+
+ if (userRole === 'superuser' && !primaryUnitId && req.user?.role !== 'superuser') {
+ return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
+ }
// Generate employee number if not provided
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
@@ -375,6 +495,48 @@ router.post('/',
})
}
+ let resolvedDepartment = department
+ let resolvedPrimaryUnitId: string | null = primaryUnitId || null
+ let resolvedAssignmentRole = assignmentRole || 'mitarbeiter'
+
+ if (primaryUnitId) {
+ const unit = db.prepare('SELECT id, type, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(primaryUnitId) as { id: string; type: OrganizationalUnitType; code: string | null; name: string } | undefined
+ if (!unit) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
+ }
+
+ if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unit.type)) {
+ return res.status(400).json({ success: false, error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unit.type} zugeordnet werden` } })
+ }
+
+ resolvedDepartment = unit.code || unit.name
+ resolvedPrimaryUnitId = unit.id
+ }
+
+ if (req.user?.role === 'superuser') {
+ const canManage = req.user.canManageEmployees
+ if (!canManage) {
+ return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
+ }
+
+ if (!req.user.powerUnitId) {
+ return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
+ }
+
+ if (primaryUnitId && primaryUnitId !== req.user.powerUnitId) {
+ return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
+ }
+
+ const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
+ if (!unitRow) {
+ return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
+ }
+
+ resolvedPrimaryUnitId = unitRow.id
+ resolvedDepartment = unitRow.code || unitRow.name
+ resolvedAssignmentRole = 'mitarbeiter'
+ }
+
// Insert employee with encrypted fields
encryptedDb.insertEmployee({
id: employeeId,
@@ -384,7 +546,7 @@ router.post('/',
photo: photo || null,
position,
official_title: officialTitle || null,
- department,
+ department: resolvedDepartment,
email,
phone,
mobile: mobile || null,
@@ -393,24 +555,20 @@ router.post('/',
clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null,
- primary_unit_id: primaryUnitId || null,
+ primary_unit_id: resolvedPrimaryUnitId,
created_at: now,
updated_at: now,
created_by: req.user!.id
})
// Create primary assignment if provided
- if (primaryUnitId) {
- const unit = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(primaryUnitId)
- if (!unit) {
- return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
- }
+ if (resolvedPrimaryUnitId) {
db.prepare(`
INSERT INTO employee_unit_assignments (
id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
- uuidv4(), employeeId, primaryUnitId, assignmentRole || 'mitarbeiter',
+ uuidv4(), employeeId, resolvedPrimaryUnitId, resolvedAssignmentRole,
now, null, 1, now, now
)
}
@@ -486,10 +644,20 @@ router.post('/',
const hashedPassword = bcrypt.hashSync(tempPassword, 12)
// Enforce role policy: only admins may assign roles; others default to 'user'
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : 'user'
-
+
+ let powerUnitForUser: string | null = null
+ let powerFunctionForUser: string | null = null
+ if (assignedRole === 'superuser') {
+ if (!resolvedPrimaryUnitId || !requestedPowerFunction) {
+ throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
+ }
+ powerUnitForUser = resolvedPrimaryUnitId
+ powerFunctionForUser = requestedPowerFunction
+ }
+
db.prepare(`
- INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
email,
@@ -498,6 +666,8 @@ router.post('/',
hashedPassword,
assignedRole,
employeeId,
+ powerUnitForUser,
+ powerFunctionForUser,
1,
now,
now
@@ -593,15 +763,15 @@ router.put('/:id',
authenticate,
requireEditPermission(req => req.params.id),
[
- body('firstName').notEmpty().trim().escape(),
- body('lastName').notEmpty().trim().escape(),
- body('position').optional().trim().escape(),
- body('officialTitle').optional().trim().escape(),
- body('department').notEmpty().trim().escape(),
+ body('firstName').notEmpty().trim(),
+ body('lastName').notEmpty().trim(),
+ body('position').optional().trim(),
+ body('officialTitle').optional().trim(),
+ body('department').notEmpty().trim(),
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'])
+ body('availability').optional().isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@@ -618,13 +788,13 @@ router.put('/:id',
const now = new Date().toISOString()
const {
- firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
+ firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
mobile, office, availability = 'available', clearance, skills, languages, specializations,
employeeNumber
} = req.body
// Check if employee exists
- const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@@ -727,6 +897,10 @@ router.put('/:id',
logger.error('Failed to queue sync:', err)
})
+ if (availability === 'available') {
+ clearActiveDeputiesForPrincipal(id)
+ }
+
return res.json({
success: true,
message: 'Employee updated successfully'
@@ -762,7 +936,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
- const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
+ const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
diff --git a/backend/src/routes/officialTitles.ts b/backend/src/routes/officialTitles.ts
new file mode 100644
index 0000000..6192dfc
--- /dev/null
+++ b/backend/src/routes/officialTitles.ts
@@ -0,0 +1,158 @@
+import { Router, Response, NextFunction } from 'express'
+import { body, validationResult } from 'express-validator'
+import { v4 as uuidv4 } from 'uuid'
+import { db } from '../config/secureDatabase'
+import { authenticate, AuthRequest } from '../middleware/auth'
+import { requirePermission } from '../middleware/roleAuth'
+import { logger } from '../utils/logger'
+
+const router = Router()
+
+const mapRow = (row: any) => ({
+ id: row.id,
+ label: row.label,
+ orderIndex: row.order_index ?? row.orderIndex ?? 0,
+ isActive: row.is_active === undefined ? row.isActive ?? true : Boolean(row.is_active),
+ createdAt: row.created_at ?? row.createdAt,
+ updatedAt: row.updated_at ?? row.updatedAt,
+})
+
+// Public list for regular users (active titles only)
+router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const titles = db.prepare(
+ 'SELECT id, label, order_index, is_active FROM official_titles WHERE is_active = 1 ORDER BY order_index ASC, label COLLATE NOCASE ASC'
+ ).all()
+ res.json({
+ success: true,
+ data: titles.map((row: any) => ({ id: row.id, label: row.label })),
+ })
+ } catch (error) {
+ next(error)
+ }
+})
+
+// Admin list with full metadata
+router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const titles = db.prepare(
+ 'SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles ORDER BY order_index ASC, label COLLATE NOCASE ASC'
+ ).all()
+ res.json({ success: true, data: titles.map(mapRow) })
+ } catch (error) {
+ next(error)
+ }
+})
+
+router.post('/',
+ authenticate,
+ requirePermission('settings:update'),
+ [body('label').trim().notEmpty()],
+ (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
+ }
+
+ const { label } = req.body as { label: string }
+ const now = new Date().toISOString()
+ const maxOrder = db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM official_titles').get() as { maxOrder: number }
+ const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
+ const id = uuidv4()
+
+ db.prepare(`
+ INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, 1, ?, ?)
+ `).run(id, label.trim(), nextOrder, now, now)
+
+ logger.info(`Official title "${label}" created by user ${req.user?.username}`)
+
+ res.status(201).json({ success: true, data: { id, label: label.trim(), orderIndex: nextOrder, isActive: true } })
+ } catch (error) {
+ next(error)
+ }
+ }
+)
+
+router.put('/:id',
+ authenticate,
+ requirePermission('settings:update'),
+ [
+ body('label').optional().trim().notEmpty(),
+ body('isActive').optional().isBoolean(),
+ body('orderIndex').optional().isInt({ min: 0 }),
+ ],
+ (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
+ }
+
+ const { id } = req.params
+ const { label, isActive, orderIndex } = req.body as { label?: string; isActive?: boolean; orderIndex?: number }
+ const now = new Date().toISOString()
+
+ const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
+ if (!existing) {
+ return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
+ }
+
+ const fields: string[] = []
+ const values: any[] = []
+
+ if (label !== undefined) {
+ fields.push('label = ?')
+ values.push(label.trim())
+ }
+ if (isActive !== undefined) {
+ fields.push('is_active = ?')
+ values.push(isActive ? 1 : 0)
+ }
+ if (orderIndex !== undefined) {
+ fields.push('order_index = ?')
+ values.push(orderIndex)
+ }
+
+ if (fields.length === 0) {
+ return res.json({ success: true, message: 'No changes applied' })
+ }
+
+ fields.push('updated_at = ?')
+ values.push(now)
+
+ values.push(id)
+
+ db.prepare(`
+ UPDATE official_titles SET ${fields.join(', ')} WHERE id = ?
+ `).run(...values)
+
+ logger.info(`Official title ${id} updated by user ${req.user?.username}`)
+
+ const updated = db.prepare('SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles WHERE id = ?').get(id)
+ res.json({ success: true, data: mapRow(updated) })
+ } catch (error) {
+ next(error)
+ }
+ }
+)
+
+router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const { id } = req.params
+ const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
+ if (!existing) {
+ return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
+ }
+
+ db.prepare('DELETE FROM official_titles WHERE id = ?').run(id)
+ logger.info(`Official title ${id} deleted by user ${req.user?.username}`)
+
+ res.json({ success: true })
+ } catch (error) {
+ next(error)
+ }
+})
+
+export default router
diff --git a/backend/src/routes/organization.ts b/backend/src/routes/organization.ts
index e1ceee2..7ab8a53 100644
--- a/backend/src/routes/organization.ts
+++ b/backend/src/routes/organization.ts
@@ -5,15 +5,72 @@ import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import {
OrganizationalUnit,
+ OrganizationalUnitType,
EmployeeUnitAssignment,
SpecialPosition,
DeputyAssignment,
DeputyDelegation
} from '@skillmate/shared'
import { logger } from '../utils/logger'
+import { decodeHtmlEntities } from '../utils/html'
const router = Router()
+function toSqlDateTime(date: Date): string {
+ const pad = (value: number) => value.toString().padStart(2, '0')
+ const year = date.getUTCFullYear()
+ const month = pad(date.getUTCMonth() + 1)
+ const day = pad(date.getUTCDate())
+ const hours = pad(date.getUTCHours())
+ const minutes = pad(date.getUTCMinutes())
+ const seconds = pad(date.getUTCSeconds())
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+function normalizeAssignmentRange(validFrom: string, validUntil?: string | null) {
+ if (!validFrom) {
+ throw new Error('Startdatum ist erforderlich')
+ }
+ if (!validUntil) {
+ throw new Error('Enddatum ist erforderlich')
+ }
+
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(validUntil)) {
+ throw new Error('Datumsangaben müssen im Format JJJJ-MM-TT erfolgen')
+ }
+
+ const startDate = new Date(`${validFrom}T00:00:00Z`)
+ if (Number.isNaN(startDate.getTime())) {
+ throw new Error('Ungültiges Startdatum')
+ }
+
+ const endDate = new Date(`${validUntil}T23:59:59Z`)
+ if (Number.isNaN(endDate.getTime())) {
+ throw new Error('Ungültiges Enddatum')
+ }
+
+ if (endDate.getTime() < startDate.getTime()) {
+ throw new Error('Enddatum muss nach dem Startdatum liegen')
+ }
+
+ const startSql = toSqlDateTime(startDate)
+ const endSql = toSqlDateTime(endDate)
+
+ return { startSql, endSql }
+}
+
+const PARENT_RULES: Record = {
+ direktion: null,
+ abteilung: ['direktion'],
+ dezernat: ['abteilung'],
+ sachgebiet: ['dezernat', 'teildezernat'],
+ teildezernat: ['dezernat'],
+ ermittlungskommission: ['dezernat'],
+ fuehrungsstelle: ['abteilung'],
+ stabsstelle: ['direktion'],
+ sondereinheit: ['direktion', 'abteilung']
+}
+
// Get all organizational units
router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
try {
@@ -32,7 +89,14 @@ router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
ORDER BY level, order_index, name
`).all()
- res.json({ success: true, data: units })
+ const decodedUnits = units.map((unit: any) => ({
+ ...unit,
+ code: decodeHtmlEntities(unit.code) ?? unit.code,
+ name: decodeHtmlEntities(unit.name) ?? unit.name,
+ description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
+ }))
+
+ res.json({ success: true, data: decodedUnits })
} catch (error) {
logger.error('Error fetching organizational units:', error)
next(error)
@@ -171,6 +235,18 @@ router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
// Apply sorting from the top
rootUnits.forEach(sortTree)
+ const decodeTree = (node: any) => {
+ if (!node) return
+ node.code = decodeHtmlEntities(node.code) ?? node.code
+ node.name = decodeHtmlEntities(node.name) ?? node.name
+ node.description = decodeHtmlEntities(node.description) ?? (decodeHtmlEntities(node.name) ?? node.name)
+ if (Array.isArray(node.children)) {
+ node.children.forEach(decodeTree)
+ }
+ }
+
+ rootUnits.forEach(decodeTree)
+
res.json({ success: true, data: rootUnits })
} catch (error) {
logger.error('Error building organizational hierarchy:', error)
@@ -218,11 +294,28 @@ router.get('/units/:id', authenticate, async (req: AuthRequest, res, next) => {
e.last_name, e.first_name
`).all(req.params.id)
- res.json({
- success: true,
+ const decodedUnit = {
+ ...unit,
+ code: decodeHtmlEntities(unit.code) ?? unit.code,
+ name: decodeHtmlEntities(unit.name) ?? unit.name,
+ description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
+ }
+
+ const decodedEmployees = employees.map((emp: any) => ({
+ ...emp,
+ firstName: decodeHtmlEntities(emp.firstName) ?? emp.firstName,
+ lastName: decodeHtmlEntities(emp.lastName) ?? emp.lastName,
+ position: decodeHtmlEntities(emp.position) ?? emp.position,
+ department: decodeHtmlEntities(emp.department) ?? emp.department,
+ email: decodeHtmlEntities(emp.email) ?? emp.email,
+ phone: decodeHtmlEntities(emp.phone) ?? emp.phone
+ }))
+
+ res.json({
+ success: true,
data: {
- ...unit,
- employees
+ ...decodedUnit,
+ employees: decodedEmployees
}
})
} catch (error) {
@@ -237,7 +330,7 @@ router.post('/units',
[
body('code').notEmpty().trim(),
body('name').notEmpty().trim(),
- body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit']),
+ body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('level').isInt({ min: 0, max: 10 }),
body('parentId').optional({ checkFalsy: true }).isUUID()
],
@@ -263,6 +356,22 @@ router.post('/units',
return res.status(400).json({ success: false, error: { message: 'Unit code already exists' } })
}
+ const unitType = type as OrganizationalUnitType
+ const requiredParents = PARENT_RULES[unitType] ?? null
+ let parentType: OrganizationalUnitType | null = null
+ if (parentId) {
+ const parentRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; type: OrganizationalUnitType } | undefined
+ if (!parentRow) {
+ return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
+ }
+ parentType = parentRow.type
+ if (requiredParents && requiredParents.length > 0 && !requiredParents.includes(parentType)) {
+ return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
+ }
+ } else if (requiredParents && requiredParents.length > 0) {
+ return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
+ }
+
// Get max order index for this level
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM organizational_units WHERE level = ?').get(level) as any
const orderIndex = (maxOrder?.max || 0) + 1
@@ -294,9 +403,13 @@ router.put('/units/:id',
authenticate,
[
param('id').isUUID(),
+ body('code').optional().isString().trim().notEmpty(),
body('name').optional().notEmpty().trim(),
+ body('type').optional().isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('description').optional(),
body('color').optional(),
+ body('hasFuehrungsstelle').optional().isBoolean().toBoolean(),
+ body('fuehrungsstelleName').optional().isString().trim(),
body('parentId').optional({ checkFalsy: true }).isUUID(),
body('level').optional().isInt({ min: 0, max: 10 }),
// allow updating persisted canvas positions from admin editor
@@ -305,67 +418,126 @@ router.put('/units/:id',
],
async (req: AuthRequest, res: any, next: any) => {
try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
+ }
+
if (req.user?.role !== 'admin') {
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
}
- const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
+ const { code, name, type, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const now = new Date().toISOString()
- // Optional: validate parentId and avoid cycles
- let newParentId: string | null | undefined = undefined
+ const existingUnit = db.prepare('SELECT id, type, parent_id as parentId FROM organizational_units WHERE id = ?').get(req.params.id) as { id: string; type: OrganizationalUnitType; parentId: string | null } | undefined
+ if (!existingUnit) {
+ return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
+ }
+
+ // Resolve target type
+ const targetType: OrganizationalUnitType = (type as OrganizationalUnitType) || existingUnit.type
+
+ // Determine final parent
+ let finalParentId: string | null = existingUnit.parentId || null
+ let finalParentType: OrganizationalUnitType | null = null
+
if (parentId !== undefined) {
- if (parentId === null || parentId === '' ) {
- newParentId = null
+ if (parentId === null || parentId === '') {
+ finalParentId = null
} else {
- const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId)
- if (!parent) {
+ const parentRow = db.prepare('SELECT id, parent_id as parentId, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; parentId: string | null; type: OrganizationalUnitType } | undefined
+ if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
}
- // cycle check: walk up from parent to root; id must not appear
+ // cycle check
const targetId = req.params.id
- let cursor: any = parent
+ let cursor: any = parentRow
while (cursor && cursor.parentId) {
if (cursor.parentId === targetId) {
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } })
}
cursor = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(cursor.parentId)
}
- newParentId = parentId
+ finalParentId = parentRow.id
+ finalParentType = parentRow.type
}
}
- const result = db.prepare(`
- UPDATE organizational_units
- SET name = COALESCE(?, name),
- description = COALESCE(?, description),
- color = COALESCE(?, color),
- has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
- fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
- parent_id = COALESCE(?, parent_id),
- level = COALESCE(?, level),
- position_x = COALESCE(?, position_x),
- position_y = COALESCE(?, position_y),
- updated_at = ?
- WHERE id = ?
- `).run(
- name || null,
- description !== undefined ? description : null,
- color || null,
- hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
- fuehrungsstelleName || null,
- newParentId !== undefined ? newParentId : null,
- level !== undefined ? Number(level) : null,
- positionX !== undefined ? Math.round(Number(positionX)) : null,
- positionY !== undefined ? Math.round(Number(positionY)) : null,
- now,
- req.params.id
- )
-
- if (result.changes === 0) {
- return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
+ if (finalParentId && !finalParentType) {
+ const parentRow = db.prepare('SELECT type FROM organizational_units WHERE id = ?').get(finalParentId) as { type: OrganizationalUnitType } | undefined
+ finalParentType = parentRow?.type ?? null
}
+ const allowedParents = PARENT_RULES[targetType] ?? null
+ if (allowedParents && allowedParents.length > 0) {
+ if (!finalParentId) {
+ return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
+ }
+ if (!finalParentType || !allowedParents.includes(finalParentType)) {
+ return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
+ }
+ }
+
+ const updates: string[] = []
+ const params: any[] = []
+
+ if (code !== undefined) {
+ updates.push('code = ?')
+ params.push(code)
+ }
+ if (name !== undefined) {
+ updates.push('name = ?')
+ params.push(name)
+ }
+ if (type !== undefined) {
+ updates.push('type = ?')
+ params.push(type)
+ }
+ if (description !== undefined) {
+ updates.push('description = ?')
+ params.push(description)
+ }
+ if (color !== undefined) {
+ updates.push('color = ?')
+ params.push(color)
+ }
+ if (hasFuehrungsstelle !== undefined) {
+ updates.push('has_fuehrungsstelle = ?')
+ params.push(hasFuehrungsstelle ? 1 : 0)
+ }
+ if (fuehrungsstelleName !== undefined) {
+ updates.push('fuehrungsstelle_name = ?')
+ params.push(fuehrungsstelleName)
+ }
+ if (parentId !== undefined) {
+ updates.push('parent_id = ?')
+ params.push(finalParentId)
+ }
+ if (level !== undefined) {
+ updates.push('level = ?')
+ params.push(Number(level))
+ }
+ if (positionX !== undefined) {
+ updates.push('position_x = ?')
+ params.push(Math.round(Number(positionX)))
+ }
+ if (positionY !== undefined) {
+ updates.push('position_y = ?')
+ params.push(Math.round(Number(positionY)))
+ }
+
+ updates.push('updated_at = ?')
+ params.push(now)
+
+ if (updates.length === 0) {
+ return res.json({ success: true, message: 'No changes applied' })
+ }
+
+ params.push(req.params.id)
+ const stmt = db.prepare(`UPDATE organizational_units SET ${updates.join(', ')} WHERE id = ?`)
+ stmt.run(...params)
+
res.json({ success: true })
} catch (error) {
logger.error('Error updating organizational unit:', error)
@@ -405,7 +577,7 @@ router.post('/assignments',
}
// Validate unit exists and is active
- const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
+ const unit = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId) as { id: string; code?: string | null; name?: string | null } | undefined
if (!unit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
}
@@ -427,6 +599,23 @@ router.post('/assignments',
SET is_primary = 0, updated_at = ?
WHERE employee_id = ? AND end_date IS NULL
`).run(now, employeeId)
+
+ // Keep employees.primary_unit_id in sync for listings
+ db.prepare(`
+ UPDATE employees
+ SET primary_unit_id = ?, updated_at = ?
+ WHERE id = ?
+ `).run(unitId, now, employeeId)
+
+ // Update department text (backward compatibility for older UIs/exports)
+ const deptText = (unit.code && String(unit.code).trim().length > 0) ? unit.code : (unit.name || null)
+ if (deptText) {
+ db.prepare(`
+ UPDATE employees
+ SET department = ?, updated_at = ?
+ WHERE id = ?
+ `).run(deptText, now, employeeId)
+ }
}
db.prepare(`
@@ -544,15 +733,39 @@ router.post('/deputies',
const now = new Date().toISOString()
const assignmentId = uuidv4()
- // Check for conflicts
+ let range
+ try {
+ range = normalizeAssignmentRange(validFrom, validUntil)
+ } catch (error: any) {
+ return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
+ }
+
+ const { startSql, endSql } = range
+
const conflict = db.prepare(`
- SELECT id FROM deputy_assignments
+ SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
+ FROM deputy_assignments
WHERE principal_id = ? AND deputy_id = ?
- AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
- `).get(req.user.employeeId, deputyId, validFrom, validUntil, validFrom, validUntil)
+ AND valid_until >= ?
+ AND valid_from <= ?
+ `).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
- return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
+ db.prepare(`
+ UPDATE deputy_assignments
+ SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
+ WHERE id = ?
+ `).run(
+ unitId || conflict.unitId || null,
+ startSql,
+ endSql,
+ reason !== undefined ? (reason || null) : (conflict.existingReason || null),
+ canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
+ now,
+ conflict.id
+ )
+
+ return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@@ -563,7 +776,7 @@ router.post('/deputies',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
- validFrom, validUntil, reason || null, canDelegate ? 1 : 0,
+ startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)
@@ -581,7 +794,7 @@ router.post('/deputies/my',
[
body('deputyId').isUUID(),
body('validFrom').isISO8601(),
- body('validUntil').optional({ nullable: true }).isISO8601(),
+ body('validUntil').isISO8601(),
body('unitId').optional({ nullable: true }).isUUID()
],
async (req: AuthRequest, res: any, next: any) => {
@@ -599,15 +812,39 @@ router.post('/deputies/my',
const now = new Date().toISOString()
const assignmentId = uuidv4()
- // Check for conflicts
+ let range
+ try {
+ range = normalizeAssignmentRange(validFrom, validUntil)
+ } catch (error: any) {
+ return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
+ }
+
+ const { startSql, endSql } = range
+
const conflict = db.prepare(`
- SELECT id FROM deputy_assignments
+ SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
+ FROM deputy_assignments
WHERE principal_id = ? AND deputy_id = ?
- AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
- `).get(req.user.employeeId, deputyId, validFrom, validUntil || validFrom, validFrom, validUntil || validFrom)
+ AND valid_until >= ?
+ AND valid_from <= ?
+ `).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
- return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
+ db.prepare(`
+ UPDATE deputy_assignments
+ SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
+ WHERE id = ?
+ `).run(
+ unitId || conflict.unitId || null,
+ startSql,
+ endSql,
+ reason !== undefined ? (reason || null) : (conflict.existingReason || null),
+ canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
+ now,
+ conflict.id
+ )
+
+ return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@@ -618,7 +855,7 @@ router.post('/deputies/my',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
- validFrom, validUntil || validFrom, reason || null, canDelegate ? 1 : 0,
+ startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)
diff --git a/backend/src/routes/positions.ts b/backend/src/routes/positions.ts
new file mode 100644
index 0000000..3d391ec
--- /dev/null
+++ b/backend/src/routes/positions.ts
@@ -0,0 +1,242 @@
+import { Router, Response, NextFunction } from 'express'
+import { body, validationResult } from 'express-validator'
+import { v4 as uuidv4 } from 'uuid'
+import { db } from '../config/secureDatabase'
+import { authenticate, AuthRequest } from '../middleware/auth'
+import { requirePermission } from '../middleware/roleAuth'
+import { logger } from '../utils/logger'
+
+const router = Router()
+
+interface PositionRow {
+ id: string
+ label: string
+ organization_unit_id: string | null
+ order_index?: number
+ is_active?: number | boolean
+ created_at?: string
+ updated_at?: string
+}
+
+const mapRow = (row: PositionRow) => ({
+ id: row.id,
+ label: row.label,
+ organizationUnitId: row.organization_unit_id || null,
+ orderIndex: row.order_index ?? 0,
+ isActive: row.is_active === undefined ? true : Boolean(row.is_active),
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+})
+
+const sanitizeUnitId = (value: unknown): string | null => {
+ if (typeof value !== 'string') return null
+ const trimmed = value.trim()
+ if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'global') {
+ return null
+ }
+ return trimmed
+}
+
+router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const unitId = sanitizeUnitId(req.query.unitId)
+
+ const fetchGlobal = (): PositionRow[] => db.prepare(
+ `SELECT id, label, organization_unit_id, order_index, is_active
+ FROM position_catalog
+ WHERE is_active = 1 AND organization_unit_id IS NULL
+ ORDER BY order_index ASC, label COLLATE NOCASE ASC`
+ ).all() as PositionRow[]
+
+ const fetchScoped = (orgId: string): PositionRow[] => db.prepare(
+ `SELECT id, label, organization_unit_id, order_index, is_active
+ FROM position_catalog
+ WHERE is_active = 1 AND organization_unit_id = ?
+ ORDER BY order_index ASC, label COLLATE NOCASE ASC`
+ ).all(orgId) as PositionRow[]
+
+ let rows: PositionRow[]
+ if (unitId) {
+ const scoped = fetchScoped(unitId)
+ const global = fetchGlobal()
+ const seen = new Set()
+ rows = [...scoped, ...global].filter((row) => {
+ const key = String(row.label || '').trim().toLowerCase()
+ if (!key) return false
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+ } else {
+ rows = fetchGlobal()
+ }
+
+ res.json({
+ success: true,
+ data: rows.map((row) => ({
+ id: row.id,
+ label: row.label,
+ organizationUnitId: row.organization_unit_id || null
+ }))
+ })
+ } catch (error) {
+ next(error)
+ }
+})
+
+router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const unitId = sanitizeUnitId(req.query.unitId)
+ const statement = unitId
+ ? db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id = ? ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
+ : db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id IS NULL ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
+
+ const rows = (unitId ? statement.all(unitId) : statement.all()) as PositionRow[]
+ res.json({ success: true, data: rows.map(mapRow) })
+ } catch (error) {
+ next(error)
+ }
+})
+
+router.post('/',
+ authenticate,
+ requirePermission('settings:update'),
+ [
+ body('label').trim().notEmpty(),
+ body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
+ ],
+ (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
+ }
+
+ const { label, organizationUnitId } = req.body as { label: string; organizationUnitId?: string | null }
+ const unitId = sanitizeUnitId(organizationUnitId)
+ const now = new Date().toISOString()
+ const id = uuidv4()
+
+ const maxOrder = unitId
+ ? (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id = ?').get(unitId) as { maxOrder: number })
+ : (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id IS NULL').get() as { maxOrder: number })
+ const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
+
+ const insert = db.prepare(`
+ INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 1, ?, ?)
+ `)
+
+ try {
+ insert.run(id, label.trim(), unitId, nextOrder, now, now)
+ } catch (error: any) {
+ if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
+ return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
+ }
+ throw error
+ }
+
+ logger.info(`Position "${label}" created by ${req.user?.username}${unitId ? ` for unit ${unitId}` : ''}`)
+
+ res.status(201).json({ success: true, data: { id, label: label.trim(), organizationUnitId: unitId, orderIndex: nextOrder, isActive: true } })
+ } catch (error) {
+ next(error)
+ }
+ }
+)
+
+router.put('/:id',
+ authenticate,
+ requirePermission('settings:update'),
+ [
+ body('label').optional().trim().notEmpty(),
+ body('isActive').optional().isBoolean(),
+ body('orderIndex').optional().isInt({ min: 0 }),
+ body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
+ ],
+ (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
+ }
+
+ const { id } = req.params
+ const { label, isActive, orderIndex, organizationUnitId } = req.body as { label?: string; isActive?: boolean; orderIndex?: number; organizationUnitId?: string | null }
+ const unitId = organizationUnitId !== undefined ? sanitizeUnitId(organizationUnitId) : undefined
+ const now = new Date().toISOString()
+
+ const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
+ if (!existing) {
+ return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
+ }
+
+ const fields: string[] = []
+ const values: any[] = []
+
+ if (label !== undefined) {
+ fields.push('label = ?')
+ values.push(label.trim())
+ }
+ if (isActive !== undefined) {
+ fields.push('is_active = ?')
+ values.push(isActive ? 1 : 0)
+ }
+ if (orderIndex !== undefined) {
+ fields.push('order_index = ?')
+ values.push(orderIndex)
+ }
+ if (unitId !== undefined) {
+ fields.push('organization_unit_id = ?')
+ values.push(unitId)
+ }
+
+ if (fields.length === 0) {
+ return res.json({ success: true, message: 'Keine Änderungen erforderlich.' })
+ }
+
+ fields.push('updated_at = ?')
+ values.push(now)
+ values.push(id)
+
+ const update = db.prepare(`
+ UPDATE position_catalog SET ${fields.join(', ')} WHERE id = ?
+ `)
+
+ try {
+ update.run(...values)
+ } catch (error: any) {
+ if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
+ return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
+ }
+ throw error
+ }
+
+ logger.info(`Position ${id} updated by ${req.user?.username}`)
+
+ const refreshed = db.prepare('SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE id = ?').get(id) as PositionRow | undefined
+ res.json({ success: true, data: refreshed ? mapRow(refreshed) : null })
+ } catch (error) {
+ next(error)
+ }
+ }
+)
+
+router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const { id } = req.params
+ const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
+ if (!existing) {
+ return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
+ }
+
+ db.prepare('DELETE FROM position_catalog WHERE id = ?').run(id)
+ logger.info(`Position ${id} deleted by ${req.user?.username}`)
+
+ res.json({ success: true })
+ } catch (error) {
+ next(error)
+ }
+})
+
+export default router
diff --git a/backend/src/routes/usersAdmin.ts b/backend/src/routes/usersAdmin.ts
index 844ae8b..c9d2665 100644
--- a/backend/src/routes/usersAdmin.ts
+++ b/backend/src/routes/usersAdmin.ts
@@ -4,7 +4,7 @@ import bcrypt from 'bcrypt'
import { v4 as uuidv4 } from 'uuid'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
-import { User, UserRole } from '@skillmate/shared'
+import { User, UserRole, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
@@ -15,9 +15,23 @@ const router = Router()
router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthRequest, res, next) => {
try {
const users = db.prepare(`
- SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
- FROM users
- ORDER BY username
+ SELECT
+ u.id,
+ u.username,
+ u.email,
+ u.role,
+ u.employee_id,
+ u.last_login,
+ u.is_active,
+ u.created_at,
+ u.updated_at,
+ u.power_unit_id,
+ u.power_function,
+ ou.name AS power_unit_name,
+ ou.type AS power_unit_type
+ FROM users u
+ LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
+ ORDER BY u.username
`).all() as any[]
// Decrypt email addresses (handle decryption failures)
@@ -35,6 +49,9 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
}
}
+ const powerDef = POWER_FUNCTIONS.find(def => def.id === user.power_function)
+ const canManageEmployees = user.role === 'admin' || (user.role === 'superuser' && powerDef?.canManageEmployees)
+
return {
...user,
email: decryptedEmail,
@@ -42,7 +59,12 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
lastLogin: user.last_login ? new Date(user.last_login) : null,
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at),
- employeeId: user.employee_id
+ employeeId: user.employee_id,
+ powerUnitId: user.power_unit_id || null,
+ powerUnitName: user.power_unit_name || null,
+ powerUnitType: user.power_unit_type || null,
+ powerFunction: user.power_function || null,
+ canManageEmployees: Boolean(canManageEmployees)
}
})
@@ -58,7 +80,9 @@ router.put('/:id/role',
authenticate,
authorize('admin'),
[
- body('role').isIn(['admin', 'superuser', 'user'])
+ body('role').isIn(['admin', 'superuser', 'user']),
+ body('powerUnitId').optional({ nullable: true }).isUUID(),
+ body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -71,7 +95,7 @@ router.put('/:id/role',
}
const { id } = req.params
- const { role } = req.body
+ const { role, powerUnitId, powerFunction } = req.body as { role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
@@ -90,11 +114,40 @@ router.put('/:id/role',
})
}
- // Update role
+ let finalPowerUnit: string | null = null
+ let finalPowerFunction: string | null = null
+
+ if (role === 'superuser') {
+ if (!powerUnitId || !powerFunction) {
+ return res.status(400).json({
+ success: false,
+ error: { message: 'Poweruser requires organizational unit and Funktion' }
+ })
+ }
+
+ const functionDef = POWER_FUNCTIONS.find(def => def.id === powerFunction)
+ if (!functionDef) {
+ return res.status(400).json({ success: false, error: { message: 'Ungültige Poweruser-Funktion' } })
+ }
+
+ const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
+ if (!unitRow) {
+ return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
+ }
+
+ if (!functionDef.unitTypes.includes(unitRow.type)) {
+ return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
+ }
+
+ finalPowerUnit = powerUnitId
+ finalPowerFunction = powerFunction
+ }
+
+ const now = new Date().toISOString()
db.prepare(`
- UPDATE users SET role = ?, updated_at = ?
+ UPDATE users SET role = ?, power_unit_id = ?, power_function = ?, updated_at = ?
WHERE id = ?
- `).run(role, new Date().toISOString(), id)
+ `).run(role, finalPowerUnit, finalPowerFunction, now, id)
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
res.json({ success: true, message: 'Role updated successfully' })
@@ -124,6 +177,13 @@ router.post('/bulk-create-from-employees',
}
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
+
+ if (role === 'superuser') {
+ return res.status(400).json({
+ success: false,
+ error: { message: 'Bulk-Erstellung für Poweruser wird nicht unterstützt. Bitte einzeln mit Organisationszuordnung anlegen.' }
+ })
+ }
const results: any[] = []
for (const employeeId of employeeIds) {
@@ -155,8 +215,8 @@ router.post('/bulk-create-from-employees',
const userId = uuidv4()
db.prepare(`
- INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@@ -165,6 +225,8 @@ router.post('/bulk-create-from-employees',
hashedPassword,
role,
employeeId,
+ null,
+ null,
1,
now,
now
@@ -336,7 +398,9 @@ router.post('/create-from-employee',
[
body('employeeId').notEmpty().isString(),
body('username').optional().isString().isLength({ min: 3 }),
- body('role').isIn(['admin', 'superuser', 'user'])
+ body('role').isIn(['admin', 'superuser', 'user']),
+ body('powerUnitId').optional({ nullable: true }).isUUID(),
+ body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -348,7 +412,7 @@ router.post('/create-from-employee',
})
}
- const { employeeId, username, role } = req.body
+ const { employeeId, username, role, powerUnitId, powerFunction } = req.body as { employeeId: string; username?: string; role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check employee exists
const employee = encryptedDb.getEmployee(employeeId) as any
@@ -381,6 +445,29 @@ router.post('/create-from-employee',
}
}
+ let resolvedPowerUnit: string | null = null
+ let resolvedPowerFunction: string | null = null
+
+ if (role === 'superuser') {
+ const powerFunctionId = typeof powerFunction === 'string' ? powerFunction : null
+ const functionDef = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
+ if (!powerUnitId || !functionDef || !powerFunctionId) {
+ return res.status(400).json({ success: false, error: { message: 'Poweruser requires Organisationseinheit und Funktion' } })
+ }
+
+ const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
+ if (!unitRow) {
+ return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
+ }
+
+ if (!functionDef.unitTypes.includes(unitRow.type)) {
+ return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
+ }
+
+ resolvedPowerUnit = powerUnitId
+ resolvedPowerFunction = powerFunctionId
+ }
+
// Generate temp password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12)
@@ -389,8 +476,8 @@ router.post('/create-from-employee',
// Insert user with encrypted email
db.prepare(`
- INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@@ -399,6 +486,8 @@ router.post('/create-from-employee',
hashedPassword,
role,
employeeId,
+ resolvedPowerUnit,
+ resolvedPowerFunction,
1,
now,
now
diff --git a/backend/src/services/syncScheduler.ts b/backend/src/services/syncScheduler.ts
index 0dc7569..92c5301 100644
--- a/backend/src/services/syncScheduler.ts
+++ b/backend/src/services/syncScheduler.ts
@@ -32,6 +32,7 @@ export class SyncScheduler {
}
private initialize() {
+ this.ensureSyncSettingsTable()
// Check current sync settings on startup
this.checkAndUpdateInterval()
@@ -41,6 +42,44 @@ export class SyncScheduler {
}, 60000)
}
+ private ensureSyncSettingsTable() {
+ const now = new Date().toISOString()
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS sync_settings (
+ id TEXT PRIMARY KEY,
+ auto_sync_interval TEXT,
+ conflict_resolution TEXT CHECK(conflict_resolution IN ('admin', 'newest', 'manual')),
+ sync_employees INTEGER DEFAULT 1,
+ sync_skills INTEGER DEFAULT 1,
+ sync_users INTEGER DEFAULT 1,
+ sync_settings INTEGER DEFAULT 0,
+ bandwidth_limit INTEGER,
+ updated_at TEXT NOT NULL,
+ updated_by TEXT NOT NULL
+ )
+ `)
+
+ db.prepare(`
+ INSERT OR IGNORE INTO sync_settings (
+ id, auto_sync_interval, conflict_resolution,
+ sync_employees, sync_skills, sync_users, sync_settings,
+ bandwidth_limit, updated_at, updated_by
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ 'default',
+ 'disabled',
+ 'admin',
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ now,
+ 'system'
+ )
+ }
+
private checkAndUpdateInterval() {
try {
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
@@ -126,4 +165,4 @@ export class SyncScheduler {
}
}
-export const syncScheduler = SyncScheduler.getInstance()
\ No newline at end of file
+export const syncScheduler = SyncScheduler.getInstance()
diff --git a/backend/src/utils/department.ts b/backend/src/utils/department.ts
new file mode 100644
index 0000000..227f7fa
--- /dev/null
+++ b/backend/src/utils/department.ts
@@ -0,0 +1,143 @@
+import type * as BetterSqlite3 from 'better-sqlite3'
+import { decodeHtmlEntities } from './html'
+
+interface DepartmentSource {
+ department?: string | null
+ primaryUnitId?: string | null
+ primaryUnitCode?: string | null
+ primaryUnitName?: string | null
+ primaryUnitDescription?: string | null
+}
+
+export interface DepartmentInfo {
+ label: string
+ description?: string
+ tasks?: string
+}
+
+const decodeValue = (value?: string | null): string | undefined => {
+ if (value === null || value === undefined) return undefined
+ const decoded = decodeHtmlEntities(value)
+ const cleaned = (decoded ?? value ?? '').trim()
+ return cleaned.length > 0 ? cleaned : undefined
+}
+
+type SqliteDatabase = BetterSqlite3.Database
+
+export const createDepartmentResolver = (db: SqliteDatabase) => {
+ interface UnitRow {
+ id: string
+ code?: string | null
+ name?: string | null
+ description?: string | null
+ parent_id?: string | null
+ }
+
+ const selectById = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE id = ?')
+ const selectByCode = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE code = ? COLLATE NOCASE')
+ const selectByName = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE name = ? COLLATE NOCASE')
+
+ const resolveUnitById = (id?: string | null): UnitRow | undefined => {
+ if (!id) return undefined
+ try {
+ return selectById.get(id) as UnitRow | undefined
+ } catch {
+ return undefined
+ }
+ }
+
+ const buildPath = (unit: UnitRow): string[] => {
+ const segments: string[] = []
+ let current: UnitRow | undefined = unit
+ let guard = 0
+ while (current && guard < 20) {
+ const segment = decodeValue(current.code) || decodeValue(current.name)
+ if (segment) {
+ segments.unshift(segment)
+ }
+ current = current.parent_id ? resolveUnitById(current.parent_id) : undefined
+ guard += 1
+ }
+ return segments
+ }
+
+ const resolve = (source: DepartmentSource): DepartmentInfo => {
+ const originalDepartment = decodeValue(source.department)
+ let label = decodeValue(source.primaryUnitCode) || originalDepartment || ''
+ let description = decodeValue(source.primaryUnitName)
+ let tasks = decodeValue(source.primaryUnitDescription)
+
+ let unitRow: UnitRow | undefined = resolveUnitById(source.primaryUnitId)
+
+ const codeCandidates = [
+ decodeValue(source.primaryUnitCode),
+ decodeValue(source.department),
+ ].filter(Boolean) as string[]
+
+ if (!unitRow) {
+ for (const candidate of codeCandidates) {
+ try {
+ unitRow = selectByCode.get(candidate) as UnitRow | undefined
+ } catch {
+ unitRow = undefined
+ }
+ if (unitRow) break
+ }
+ }
+
+ if (!unitRow) {
+ const nameCandidates = [
+ decodeValue(source.primaryUnitName),
+ originalDepartment,
+ ].filter(Boolean) as string[]
+
+ for (const candidate of nameCandidates) {
+ try {
+ unitRow = selectByName.get(candidate) as UnitRow | undefined
+ } catch {
+ unitRow = undefined
+ }
+ if (unitRow) break
+ }
+ }
+
+ if (unitRow) {
+ const unitCode = decodeValue(unitRow.code)
+ const unitName = decodeValue(unitRow.name)
+ const unitDescription = decodeValue(unitRow.description)
+
+ const pathSegments = buildPath(unitRow)
+ if (pathSegments.length > 0) {
+ label = pathSegments.join(' -> ')
+ } else if (!label && unitCode) {
+ label = unitCode
+ }
+
+ if (unitName && unitName !== label) {
+ description = unitName
+ }
+
+ if (unitDescription) {
+ tasks = unitDescription
+ } else if (!tasks && unitName && unitName !== label) {
+ tasks = unitName
+ }
+ }
+
+ if (!label) {
+ label = originalDepartment || ''
+ }
+
+ if (description && description === label) {
+ description = undefined
+ }
+
+ return {
+ label,
+ description,
+ tasks,
+ }
+ }
+
+ return resolve
+}
diff --git a/backend/src/utils/html.ts b/backend/src/utils/html.ts
new file mode 100644
index 0000000..71f00a4
--- /dev/null
+++ b/backend/src/utils/html.ts
@@ -0,0 +1,53 @@
+const namedEntities: Record = {
+ amp: '&',
+ lt: '<',
+ gt: '>',
+ quot: '"',
+ apos: "'",
+ nbsp: '\u00A0',
+ slash: '/',
+ sol: '/',
+ frasl: '/',
+}
+
+const decodeSinglePass = (input: string): string => {
+ return input.replace(/&(#x?[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity) => {
+ if (!entity) {
+ return match
+ }
+ if (entity[0] === '#') {
+ const isHex = entity[1]?.toLowerCase() === 'x'
+ const codePoint = isHex
+ ? parseInt(entity.slice(2), 16)
+ : parseInt(entity.slice(1), 10)
+ if (!Number.isNaN(codePoint)) {
+ try {
+ return String.fromCodePoint(codePoint)
+ } catch {
+ return match
+ }
+ }
+ return match
+ }
+ const lowered = entity.toLowerCase()
+ if (namedEntities[lowered]) {
+ return namedEntities[lowered]
+ }
+ return match
+ })
+}
+
+export const decodeHtmlEntities = (value?: string | null): string | undefined => {
+ if (value === undefined || value === null) {
+ return undefined
+ }
+ let result = value
+ for (let i = 0; i < 3; i += 1) {
+ const decoded = decodeSinglePass(result)
+ if (decoded === result) {
+ break
+ }
+ result = decoded
+ }
+ return result
+}
diff --git a/backend/src/validation/employeeValidators.ts b/backend/src/validation/employeeValidators.ts
index c91091a..98b8940 100644
--- a/backend/src/validation/employeeValidators.ts
+++ b/backend/src/validation/employeeValidators.ts
@@ -1,20 +1,20 @@
import { body } from 'express-validator'
export const createEmployeeValidators = [
- body('firstName').notEmpty().trim().escape(),
- body('lastName').notEmpty().trim().escape(),
+ body('firstName').notEmpty().trim(),
+ body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
- body('department').notEmpty().trim().escape(),
- body('position').optional().trim().escape(),
+ body('department').notEmpty().trim(),
+ body('position').optional().trim(),
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('firstName').notEmpty().trim(),
+ body('lastName').notEmpty().trim(),
+ body('position').optional().trim(),
+ body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d155b74..106cbdd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,11 +8,11 @@
"name": "@skillmate/frontend",
"version": "1.0.0",
"dependencies": {
- "@react-three/drei": "^9.88.0",
+ "@react-three/drei": "^9.112.5",
"@react-three/fiber": "^8.15.0",
"@skillmate/shared": "file:../shared",
"@types/three": "^0.180.0",
- "axios": "^1.6.2",
+ "axios": "^1.7.9",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -29,6 +29,10 @@
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-linux-x64-gnu": "^4.52.3",
+ "@rollup/rollup-win32-x64-msvc": "^4.52.3"
}
},
"../shared": {
@@ -49,20 +53,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -79,9 +69,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
- "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
+ "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -89,22 +79,22 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
- "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
+ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.0",
+ "@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.27.3",
- "@babel/helpers": "^7.27.6",
- "@babel/parser": "^7.28.0",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.0",
- "@babel/types": "^7.28.0",
+ "@babel/traverse": "^7.28.4",
+ "@babel/types": "^7.28.4",
+ "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -120,14 +110,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
- "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.0",
- "@babel/types": "^7.28.0",
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -178,15 +168,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.27.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
- "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.27.3"
+ "@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -236,27 +226,27 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.27.6",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
- "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
- "@babel/types": "^7.27.6"
+ "@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
- "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+ "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.0"
+ "@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -322,18 +312,18 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
- "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
+ "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.0",
+ "@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.0",
+ "@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
- "@babel/types": "^7.28.0",
+ "@babel/types": "^7.28.4",
"debug": "^4.3.1"
},
"engines": {
@@ -341,9 +331,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.1",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
- "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -769,95 +759,10 @@
"node": ">=12"
}
},
- "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
- "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -865,6 +770,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -876,16 +792,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
- "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.29",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
- "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -894,11 +810,23 @@
}
},
"node_modules/@mediapipe/tasks-vision": {
- "version": "0.10.2",
- "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.2.tgz",
- "integrity": "sha512-d8Q9uRK89ZRWmED2JLI9/blpJcfdbh0iEUuMo8TgkMzNfQBY1/GC0FEJWrairTwHkxIf6Oud1vFBP+aHicWqJA==",
+ "version": "0.10.17",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0"
},
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
+ "integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -949,28 +877,27 @@
}
},
"node_modules/@react-spring/animated": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz",
- "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
+ "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
"license": "MIT",
"dependencies": {
- "@react-spring/shared": "~9.6.1",
- "@react-spring/types": "~9.6.1"
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/core": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz",
- "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
+ "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
"license": "MIT",
"dependencies": {
- "@react-spring/animated": "~9.6.1",
- "@react-spring/rafz": "~9.6.1",
- "@react-spring/shared": "~9.6.1",
- "@react-spring/types": "~9.6.1"
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
},
"funding": {
"type": "opencollective",
@@ -981,34 +908,34 @@
}
},
"node_modules/@react-spring/rafz": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz",
- "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
+ "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
"license": "MIT"
},
"node_modules/@react-spring/shared": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz",
- "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
+ "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
"license": "MIT",
"dependencies": {
- "@react-spring/rafz": "~9.6.1",
- "@react-spring/types": "~9.6.1"
+ "@react-spring/rafz": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/three": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz",
- "integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz",
+ "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==",
"license": "MIT",
"dependencies": {
- "@react-spring/animated": "~9.6.1",
- "@react-spring/core": "~9.6.1",
- "@react-spring/shared": "~9.6.1",
- "@react-spring/types": "~9.6.1"
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/core": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"@react-three/fiber": ">=6.0",
@@ -1017,46 +944,44 @@
}
},
"node_modules/@react-spring/types": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz",
- "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==",
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
+ "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
"license": "MIT"
},
"node_modules/@react-three/drei": {
- "version": "9.88.0",
- "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.88.0.tgz",
- "integrity": "sha512-iUTpurhyW+dLalRm/l+x9V1+/gxXriZoWppLiBemDno9nXUfSHPBn5kjMzpApH6VZvlSGhb7VNYrZvN9QUG3uA==",
+ "version": "9.122.0",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz",
+ "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.11.2",
- "@mediapipe/tasks-vision": "0.10.2",
- "@react-spring/three": "~9.6.1",
- "@use-gesture/react": "^10.2.24",
- "camera-controls": "^2.4.2",
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@react-spring/three": "~9.7.5",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^2.9.0",
"cross-env": "^7.0.3",
- "detect-gpu": "^5.0.28",
+ "detect-gpu": "^5.0.56",
"glsl-noise": "^0.0.0",
- "lodash.clamp": "^4.0.3",
- "lodash.omit": "^4.5.0",
- "lodash.pick": "^4.4.0",
- "maath": "^0.9.0",
- "meshline": "^3.1.6",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
"react-composer": "^5.0.3",
- "react-merge-refs": "^1.1.0",
- "stats-gl": "^1.0.4",
+ "stats-gl": "^2.2.8",
"stats.js": "^0.17.0",
"suspend-react": "^0.1.3",
- "three-mesh-bvh": "^0.6.7",
- "three-stdlib": "^2.26.6",
- "troika-three-text": "^0.47.2",
- "utility-types": "^3.10.0",
- "uuid": "^9.0.1",
- "zustand": "^3.5.13"
+ "three-mesh-bvh": "^0.7.8",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.0",
+ "tunnel-rat": "^0.1.2",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
},
"peerDependencies": {
- "@react-three/fiber": ">=8.0",
- "react": ">=18.0",
- "react-dom": ">=18.0",
+ "@react-three/fiber": "^8",
+ "react": "^18",
+ "react-dom": "^18",
"three": ">=0.137"
},
"peerDependenciesMeta": {
@@ -1066,35 +991,48 @@
}
},
"node_modules/@react-three/drei/node_modules/zustand": {
- "version": "3.7.2",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
- "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
+ "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
- "node": ">=12.7.0"
+ "node": ">=12.20.0"
},
"peerDependencies": {
- "react": ">=16.8"
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
"react": {
"optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
}
}
},
"node_modules/@react-three/fiber": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.15.0.tgz",
- "integrity": "sha512-rYefFqu6ki0iuPlIFhnfZsucJjY+4ZQNTdt+rvQgWo9f2T1Ia+1yotlNMs7jlnNG5nNBLQcq8zcz4pxbWg6rmw==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
+ "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.26.7",
+ "@types/webxr": "*",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"its-fine": "^1.0.6",
"react-reconciler": "^0.27.0",
- "react-use-measure": "^2.1.1",
+ "react-use-measure": "^2.1.7",
"scheduler": "^0.21.0",
"suspend-react": "^0.1.3",
"zustand": "^3.7.1"
@@ -1104,8 +1042,8 @@
"expo-asset": ">=8.4",
"expo-file-system": ">=11.0",
"expo-gl": ">=11.0",
- "react": ">=18.0",
- "react-dom": ">=18.0",
+ "react": ">=18 <19",
+ "react-dom": ">=18 <19",
"react-native": ">=0.64",
"three": ">=0.133"
},
@@ -1130,15 +1068,6 @@
}
}
},
- "node_modules/@react-three/fiber/node_modules/scheduler": {
- "version": "0.21.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
- "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
"node_modules/@react-three/fiber/node_modules/zustand": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
@@ -1166,16 +1095,16 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.19",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
- "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz",
- "integrity": "sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz",
+ "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==",
"cpu": [
"arm"
],
@@ -1187,9 +1116,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.0.tgz",
- "integrity": "sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz",
+ "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==",
"cpu": [
"arm64"
],
@@ -1201,9 +1130,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.0.tgz",
- "integrity": "sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz",
+ "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==",
"cpu": [
"arm64"
],
@@ -1215,9 +1144,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.0.tgz",
- "integrity": "sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz",
+ "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==",
"cpu": [
"x64"
],
@@ -1229,9 +1158,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.0.tgz",
- "integrity": "sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz",
+ "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==",
"cpu": [
"arm64"
],
@@ -1243,9 +1172,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.0.tgz",
- "integrity": "sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz",
+ "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==",
"cpu": [
"x64"
],
@@ -1257,9 +1186,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.0.tgz",
- "integrity": "sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz",
+ "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==",
"cpu": [
"arm"
],
@@ -1271,9 +1200,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.0.tgz",
- "integrity": "sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz",
+ "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==",
"cpu": [
"arm"
],
@@ -1285,9 +1214,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.0.tgz",
- "integrity": "sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz",
+ "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==",
"cpu": [
"arm64"
],
@@ -1299,9 +1228,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.0.tgz",
- "integrity": "sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz",
+ "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==",
"cpu": [
"arm64"
],
@@ -1312,10 +1241,10 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.0.tgz",
- "integrity": "sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz",
+ "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==",
"cpu": [
"loong64"
],
@@ -1326,10 +1255,10 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.0.tgz",
- "integrity": "sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==",
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz",
+ "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==",
"cpu": [
"ppc64"
],
@@ -1341,9 +1270,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.0.tgz",
- "integrity": "sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz",
+ "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==",
"cpu": [
"riscv64"
],
@@ -1355,9 +1284,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.0.tgz",
- "integrity": "sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz",
+ "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==",
"cpu": [
"riscv64"
],
@@ -1369,9 +1298,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.0.tgz",
- "integrity": "sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz",
+ "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==",
"cpu": [
"s390x"
],
@@ -1382,10 +1311,23 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz",
+ "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.0.tgz",
- "integrity": "sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz",
+ "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==",
"cpu": [
"x64"
],
@@ -1396,10 +1338,24 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz",
+ "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.0.tgz",
- "integrity": "sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz",
+ "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==",
"cpu": [
"arm64"
],
@@ -1411,9 +1367,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.0.tgz",
- "integrity": "sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz",
+ "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==",
"cpu": [
"ia32"
],
@@ -1424,10 +1380,10 @@
"win32"
]
},
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.0.tgz",
- "integrity": "sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==",
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz",
+ "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==",
"cpu": [
"x64"
],
@@ -1438,6 +1394,19 @@
"win32"
]
},
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz",
+ "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@skillmate/shared": {
"resolved": "../shared",
"link": true
@@ -1484,13 +1453,13 @@
}
},
"node_modules/@types/babel__traverse": {
- "version": "7.20.7",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
- "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.20.7"
+ "@babel/types": "^7.28.2"
}
},
"node_modules/@types/draco3d": {
@@ -1519,9 +1488,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "18.3.23",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
- "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
+ "version": "18.3.25",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz",
+ "integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1569,9 +1538,9 @@
}
},
"node_modules/@types/webxr": {
- "version": "0.5.23",
- "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.23.tgz",
- "integrity": "sha512-GPe4AsfOSpqWd3xA/0gwoKod13ChcfV67trvxaW2krUbgb9gxQjnCx8zGshzMl8LSHZlNH5gQ8LNScsDuc7nGQ==",
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@use-gesture/core": {
@@ -1593,16 +1562,16 @@
}
},
"node_modules/@vitejs/plugin-react": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
- "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/core": "^7.27.4",
+ "@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.19",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
@@ -1610,7 +1579,7 @@
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@webgpu/types": {
@@ -1620,26 +1589,26 @@
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
"engines": {
- "node": ">=8"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -1718,13 +1687,13 @@
}
},
"node_modules/axios": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
- "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
+ "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
+ "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -1755,6 +1724,16 @@
],
"license": "MIT"
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.8.10",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz",
+ "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1801,9 +1780,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.25.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
- "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
+ "version": "4.26.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
+ "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true,
"funding": [
{
@@ -1821,9 +1800,10 @@
],
"license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001726",
- "electron-to-chromium": "^1.5.173",
- "node-releases": "^2.0.19",
+ "baseline-browser-mapping": "^2.8.9",
+ "caniuse-lite": "^1.0.30001746",
+ "electron-to-chromium": "^1.5.227",
+ "node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@@ -1890,9 +1870,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001727",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
- "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
+ "version": "1.0.30001746",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
+ "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==",
"dev": true,
"funding": [
{
@@ -1980,6 +1960,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2039,9 +2029,9 @@
"license": "MIT"
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2116,16 +2106,16 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.183",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz",
- "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==",
+ "version": "1.5.228",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz",
+ "integrity": "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
@@ -2283,9 +2273,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
- "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -2319,23 +2309,10 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/foreground-child/node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/form-data": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
- "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -2433,6 +2410,27 @@
"node": ">= 0.4"
}
},
+ "node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2503,6 +2501,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
+ "integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
+ "license": "Apache-2.0"
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -2523,6 +2527,12 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2595,6 +2605,12 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2680,6 +2696,15 @@
"node": ">=6"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2700,26 +2725,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.clamp": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz",
- "integrity": "sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==",
- "license": "MIT"
- },
- "node_modules/lodash.omit": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
- "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==",
- "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.",
- "license": "MIT"
- },
- "node_modules/lodash.pick": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
- "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==",
- "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.",
- "license": "MIT"
- },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2752,13 +2757,13 @@
}
},
"node_modules/maath": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/maath/-/maath-0.9.0.tgz",
- "integrity": "sha512-aAR8hoUqPxlsU8VOxkS9y37jhUzdUxM017NpCuxFU1Gk+nMaZASZxymZrV8LRSHzRk/watlbfyNKu6XPUhCFrQ==",
+ "version": "0.10.8",
+ "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
"license": "MIT",
"peerDependencies": {
- "@types/three": ">=0.144.0",
- "three": ">=0.144.0"
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
}
},
"node_modules/math-intrinsics": {
@@ -2830,6 +2835,32 @@
"node": ">= 0.6"
}
},
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2869,9 +2900,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.19",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
- "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "version": "2.0.21",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
+ "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
"dev": true,
"license": "MIT"
},
@@ -2961,16 +2992,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/path-scurry/node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3059,29 +3080,9 @@
}
},
"node_modules/postcss-js": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
- "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "camelcase-css": "^2.0.1"
- },
- "engines": {
- "node": "^12 || ^14 || >= 16"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- "peerDependencies": {
- "postcss": "^8.4.21"
- }
- },
- "node_modules/postcss-load-config": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
- "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
@@ -3095,21 +3096,54 @@
],
"license": "MIT",
"dependencies": {
- "lilconfig": "^3.0.0",
- "yaml": "^2.3.4"
+ "camelcase-css": "^2.0.1"
},
"engines": {
- "node": ">= 14"
+ "node": "^12 || ^14 || >= 16"
},
"peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
"postcss": ">=8.0.9",
- "ts-node": ">=9.0.0"
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
"postcss": {
"optional": true
},
- "ts-node": {
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
"optional": true
}
}
@@ -3167,6 +3201,16 @@
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -3242,22 +3286,21 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-dom/node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
- "node_modules/react-merge-refs": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz",
- "integrity": "sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/gregberge"
- }
- },
"node_modules/react-reconciler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz",
@@ -3274,15 +3317,6 @@
"react": "^18.0.0"
}
},
- "node_modules/react-reconciler/node_modules/scheduler": {
- "version": "0.21.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
- "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3405,9 +3439,9 @@
}
},
"node_modules/rollup": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.0.tgz",
- "integrity": "sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==",
+ "version": "4.52.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
+ "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3421,43 +3455,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.45.0",
- "@rollup/rollup-android-arm64": "4.45.0",
- "@rollup/rollup-darwin-arm64": "4.45.0",
- "@rollup/rollup-darwin-x64": "4.45.0",
- "@rollup/rollup-freebsd-arm64": "4.45.0",
- "@rollup/rollup-freebsd-x64": "4.45.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.45.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.45.0",
- "@rollup/rollup-linux-arm64-gnu": "4.45.0",
- "@rollup/rollup-linux-arm64-musl": "4.45.0",
- "@rollup/rollup-linux-loongarch64-gnu": "4.45.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.45.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.45.0",
- "@rollup/rollup-linux-riscv64-musl": "4.45.0",
- "@rollup/rollup-linux-s390x-gnu": "4.45.0",
- "@rollup/rollup-linux-x64-gnu": "4.45.0",
- "@rollup/rollup-linux-x64-musl": "4.45.0",
- "@rollup/rollup-win32-arm64-msvc": "4.45.0",
- "@rollup/rollup-win32-ia32-msvc": "4.45.0",
- "@rollup/rollup-win32-x64-msvc": "4.45.0",
+ "@rollup/rollup-android-arm-eabi": "4.52.3",
+ "@rollup/rollup-android-arm64": "4.52.3",
+ "@rollup/rollup-darwin-arm64": "4.52.3",
+ "@rollup/rollup-darwin-x64": "4.52.3",
+ "@rollup/rollup-freebsd-arm64": "4.52.3",
+ "@rollup/rollup-freebsd-x64": "4.52.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.52.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.52.3",
+ "@rollup/rollup-linux-arm64-musl": "4.52.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.52.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.52.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.52.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.52.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.52.3",
+ "@rollup/rollup-linux-x64-gnu": "4.52.3",
+ "@rollup/rollup-linux-x64-musl": "4.52.3",
+ "@rollup/rollup-openharmony-arm64": "4.52.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.52.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.52.3",
+ "@rollup/rollup-win32-x64-gnu": "4.52.3",
+ "@rollup/rollup-win32-x64-msvc": "4.52.3",
"fsevents": "~2.3.2"
}
},
- "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.45.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.0.tgz",
- "integrity": "sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3483,9 +3505,9 @@
}
},
"node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
+ "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
@@ -3522,6 +3544,19 @@
"node": ">=8"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3533,9 +3568,23 @@
}
},
"node_modules/stats-gl": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-1.0.7.tgz",
- "integrity": "sha512-vZI82CjefSxLC1bjw36z28v0+QE9rJKymGlXtfWu+ipW70ZEAwa4EbO4LxluAfLfpqiaAS04NzpYBRLDeAwYWQ==",
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/stats.js": {
@@ -3545,18 +3594,21 @@
"license": "MIT"
},
"node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
},
"engines": {
- "node": ">=8"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
@@ -3575,7 +3627,24 @@
"node": ">=8"
}
},
- "node_modules/strip-ansi": {
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
@@ -3588,6 +3657,22 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
@@ -3602,6 +3687,16 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -3625,63 +3720,6 @@
"node": ">=16 || 14 >=14.17"
}
},
- "node_modules/sucrase/node_modules/commander": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
- "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/sucrase/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/sucrase/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/sucrase/node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3705,9 +3743,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "3.4.17",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
- "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
+ "version": "3.4.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
+ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3719,7 +3757,7 @@
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
- "jiti": "^1.21.6",
+ "jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
@@ -3728,7 +3766,7 @@
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
- "postcss-load-config": "^4.0.2",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
@@ -3766,15 +3804,16 @@
}
},
"node_modules/three": {
- "version": "0.160.0",
- "resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
- "integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
+ "version": "0.160.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
+ "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
- "version": "0.6.8",
- "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.6.8.tgz",
- "integrity": "sha512-EGebF9DZx1S8+7OZYNNTT80GXJZVf+UYXD/HyTg/e2kR/ApofIFfUS4ZzIHNnUVIadpnLSzM4n96wX+l7GMbnQ==",
+ "version": "0.7.8",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz",
+ "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==",
+ "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.",
"license": "MIT",
"peerDependencies": {
"three": ">= 0.151.0"
@@ -3817,14 +3856,14 @@
}
},
"node_modules/troika-three-text": {
- "version": "0.47.2",
- "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.47.2.tgz",
- "integrity": "sha512-qylT0F+U7xGs+/PEf3ujBdJMYWbn0Qci0kLqI5BJG2kW1wdg4T1XSxneypnF05DxFqJhEzuaOR9S2SjiyknMng==",
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
- "troika-three-utils": "^0.47.2",
- "troika-worker-utils": "^0.47.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
@@ -3832,18 +3871,18 @@
}
},
"node_modules/troika-three-utils": {
- "version": "0.47.2",
- "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.47.2.tgz",
- "integrity": "sha512-/28plhCxfKtH7MSxEGx8e3b/OXU5A0xlwl+Sbdp0H8FXUHKZDoksduEKmjQayXYtxAyuUiCRunYIv/8Vi7aiyg==",
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
- "version": "0.47.2",
- "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.47.2.tgz",
- "integrity": "sha512-mzss4MeyzUkYBppn4x5cdAqrhBHFEuVmMMgLMTyFV23x6GvQMyo+/R5E5Lsbrt7WSt5RfvewjcwD1DChRTA9lA==",
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": {
@@ -3853,10 +3892,19 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3899,9 +3947,9 @@
}
},
"node_modules/use-sync-external-store": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
- "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -3923,23 +3971,10 @@
"node": ">= 4"
}
},
- "node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/vite": {
- "version": "5.4.19",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
- "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
+ "version": "5.4.20",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
+ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4022,6 +4057,24 @@
"node": ">= 8"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
@@ -4041,6 +4094,67 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -4048,19 +4162,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/yaml": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
- "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- }
- },
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5dee3a1..1786a62 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,11 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
- "@react-three/drei": "^9.88.0",
+ "@react-three/drei": "^9.112.5",
"@react-three/fiber": "^8.15.0",
"@skillmate/shared": "file:../shared",
"@types/three": "^0.180.0",
- "axios": "^1.6.2",
+ "axios": "^1.7.9",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -31,5 +31,9 @@
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-linux-x64-gnu": "^4.52.3",
+ "@rollup/rollup-win32-x64-msvc": "^4.52.3"
}
}
diff --git a/frontend/src/components/DeputyManagement.tsx b/frontend/src/components/DeputyManagement.tsx
index 2cb0883..83c3e38 100644
--- a/frontend/src/components/DeputyManagement.tsx
+++ b/frontend/src/components/DeputyManagement.tsx
@@ -1,18 +1,28 @@
import { useState, useEffect } from 'react'
import api from '../services/api'
-import type { DeputyAssignment, Employee } from '@skillmate/shared'
+import type { Employee } from '@skillmate/shared'
import { useAuthStore } from '../stores/authStore'
+interface UnitOption {
+ id: string
+ name: string
+ code?: string | null
+ isPrimary?: boolean
+}
+
export default function DeputyManagement() {
const [asPrincipal, setAsPrincipal] = useState([])
const [asDeputy, setAsDeputy] = useState([])
const [availableEmployees, setAvailableEmployees] = useState([])
+ const [unitOptions, setUnitOptions] = useState([])
const [showAddDialog, setShowAddDialog] = useState(false)
const [loading, setLoading] = useState(true)
+ const [submitting, setSubmitting] = useState(false)
+ const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({
deputyId: '',
validFrom: new Date().toISOString().split('T')[0],
- validUntil: '',
+ validUntil: new Date().toISOString().split('T')[0],
reason: '',
canDelegate: true,
unitId: ''
@@ -22,6 +32,7 @@ export default function DeputyManagement() {
useEffect(() => {
loadDeputies()
loadEmployees()
+ loadUnits()
}, [])
const loadDeputies = async () => {
@@ -43,28 +54,66 @@ export default function DeputyManagement() {
try {
const response = await api.get('/employees/public')
if (response.data.success) {
- setAvailableEmployees(response.data.data)
+ const sorted = (response.data.data as Employee[])
+ .filter(emp => emp.id !== user?.employeeId)
+ .sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`))
+ setAvailableEmployees(sorted)
}
} catch (error) {
console.error('Failed to load employees:', error)
}
}
- const handleAddDeputy = async () => {
+ const loadUnits = async () => {
try {
- const response = await api.post('/organization/deputies/my', {
- ...formData,
- validFrom: new Date(formData.validFrom).toISOString(),
- validUntil: formData.validUntil ? new Date(formData.validUntil).toISOString() : null
- })
+ const response = await api.get('/organization/my-units')
+ if (response.data.success) {
+ setUnitOptions(response.data.data || [])
+ }
+ } catch (error) {
+ console.warn('Failed to load organizational units for deputy dialog:', error)
+ }
+ }
+
+ const handleAddDeputy = async () => {
+ if (submitting) return
+ setFormError('')
+
+ if (!formData.deputyId) {
+ setFormError('Bitte wählen Sie eine Vertretung aus.')
+ return
+ }
+
+ try {
+ setSubmitting(true)
+
+ const payload: any = {
+ deputyId: formData.deputyId,
+ validFrom: formData.validFrom,
+ validUntil: formData.validUntil,
+ reason: formData.reason || null,
+ canDelegate: formData.canDelegate
+ }
+
+ if (formData.unitId) {
+ payload.unitId = formData.unitId
+ }
+
+ const response = await api.post('/organization/deputies/my', payload)
if (response.data.success) {
await loadDeputies()
setShowAddDialog(false)
resetForm()
+ } else {
+ setFormError(response.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.')
}
- } catch (error) {
+ } catch (error: any) {
console.error('Failed to add deputy:', error)
+ const message = error?.response?.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.'
+ setFormError(message)
+ } finally {
+ setSubmitting(false)
}
}
@@ -104,14 +153,48 @@ export default function DeputyManagement() {
}
const resetForm = () => {
+ const today = new Date().toISOString().split('T')[0]
setFormData({
deputyId: '',
- validFrom: new Date().toISOString().split('T')[0],
- validUntil: '',
+ validFrom: today,
+ validUntil: today,
reason: '',
canDelegate: true,
unitId: ''
})
+ setFormError('')
+ setSubmitting(false)
+ }
+
+ const openAddDialog = () => {
+ setFormError('')
+ const today = new Date().toISOString().split('T')[0]
+
+ if (asPrincipal.length > 0) {
+ const existing = asPrincipal[0]
+ const existingFrom = existing.validFrom ? existing.validFrom.slice(0, 10) : today
+ const existingUntil = existing.validUntil ? existing.validUntil.slice(0, 10) : today
+
+ setFormData({
+ deputyId: existing.deputyId || existing.id || '',
+ validFrom: existingFrom,
+ validUntil: existingUntil < existingFrom ? existingFrom : existingUntil,
+ reason: existing.reason || '',
+ canDelegate: existing.canDelegate === 1 || existing.canDelegate === true,
+ unitId: existing.unitId || ''
+ })
+ } else {
+ setFormData({
+ deputyId: '',
+ validFrom: today,
+ validUntil: today,
+ reason: '',
+ canDelegate: true,
+ unitId: ''
+ })
+ }
+
+ setShowAddDialog(true)
}
if (loading) {
@@ -132,10 +215,10 @@ export default function DeputyManagement() {
Aktuelle Vertretungen
@@ -213,6 +296,7 @@ export default function DeputyManagement() {
onChange={(e) => setFormData({ ...formData, deputyId: e.target.value })}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
required
+ disabled={asPrincipal.length > 0}
>
{availableEmployees.map(emp => (
@@ -221,8 +305,33 @@ export default function DeputyManagement() {
))}
+ {asPrincipal.length > 0 && (
+
+ Hinweis: Es kann nur eine Vertretung gepflegt werden. Passen Sie die Zeiträume nach Bedarf an.
+
+ )}
+ {unitOptions.length > 0 && (
+
+
+
+
+ )}
+
@@ -282,6 +400,12 @@ export default function DeputyManagement() {
+ {formError && (
+
+ {formError}
+
+ )}
+
diff --git a/frontend/src/components/EmployeeCard.tsx b/frontend/src/components/EmployeeCard.tsx
index b329806..9f73483 100644
--- a/frontend/src/components/EmployeeCard.tsx
+++ b/frontend/src/components/EmployeeCard.tsx
@@ -1,11 +1,13 @@
import type { Employee } from '@skillmate/shared'
+import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
interface EmployeeCardProps {
employee: Employee
onClick: () => void
+ onDeputyNavigate?: (id: string) => void
}
-export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
+export default function EmployeeCard({ employee, onClick, onDeputyNavigate }: EmployeeCardProps) {
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const photoSrc = employee.photo && employee.photo.startsWith('/uploads/')
@@ -27,9 +29,35 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
}
const availability = getAvailabilityBadge(employee.availability)
+ const specializations = employee.specializations || []
+ const currentDeputies = employee.currentDeputies || []
+ const represents = employee.represents || []
+ const isUnavailable = employee.availability && employee.availability !== 'available'
+ const cardHighlightClasses = isUnavailable
+ ? 'border-red-300 bg-red-50 hover:border-red-400 hover:bg-red-50/90 dark:bg-red-900/20 dark:border-red-700 dark:hover:bg-red-900/30'
+ : ''
+ const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
+ const departmentDescriptionText = normalizeDepartment(departmentInfo.description)
+ const departmentTasks = normalizeDepartment(employee.departmentTasks || departmentInfo.tasks)
+ const showDepartmentDescription = departmentDescriptionText.length > 0 && departmentDescriptionText !== departmentTasks
+
+ // Show only the end unit; provide full chain as tooltip. Hide top-level root (e.g. DIR/LKA NRW)
+ const fullPath = normalizeDepartment(departmentInfo.label)
+ const splitPath = (fullPath || '').split(' -> ').map(s => s.trim()).filter(Boolean)
+ const filteredPath = splitPath.length > 0 && (/^dir$/i.test(splitPath[0]) || /^lka\s+nrw$/i.test(splitPath[0]))
+ ? splitPath.slice(1)
+ : splitPath
+ const shortDepartmentLabel = filteredPath.length > 0 ? filteredPath[filteredPath.length - 1] : (fullPath || '')
+ const chainTitle = filteredPath.join(' → ') || fullPath
+ const handleDeputyClick = (event: React.MouseEvent, targetId: string) => {
+ event.stopPropagation()
+ if (onDeputyNavigate) {
+ onDeputyNavigate(targetId)
+ }
+ }
return (
-
+
@@ -69,27 +97,100 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
)}
- Dienststelle: {employee.department}
+ Dienststelle:{' '}
+ {shortDepartmentLabel}
+ {showDepartmentDescription && (
+ {departmentDescriptionText}
+ )}
+ {departmentTasks && (
+
+ Aufgaben der Dienststelle: {departmentTasks}
+
+ )}
- {employee.specializations.length > 0 && (
+ {specializations.length > 0 && (
Spezialisierungen:
- {employee.specializations.slice(0, 3).map((spec, index) => (
+ {specializations.slice(0, 3).map((spec, index) => (
{spec}
))}
- {employee.specializations.length > 3 && (
+ {specializations.length > 3 && (
- +{employee.specializations.length - 3} weitere
+ +{specializations.length - 3} weitere
)}
)}
+
+
+ {represents.length > 0 && (
+
+ Vertritt
+
+ {represents.map(item => {
+ const label = `${item.firstName || ''} ${item.lastName || ''}`.trim()
+ const descParts = [
+ label,
+ item.position || undefined,
+ item.availability ? `Status: ${item.availability}` : undefined
+ ].filter(Boolean)
+ return (
+
+ )
+ })}
+
+
+ )}
+
+ {isUnavailable && (
+
+ Vertretung
+ {currentDeputies.length > 0 ? (
+
+ {currentDeputies.map((deputy) => {
+ const label = `${deputy.firstName || ''} ${deputy.lastName || ''}`.trim()
+ const titleParts = [
+ label,
+ deputy.position || undefined,
+ deputy.availability ? `Status: ${deputy.availability}` : undefined,
+ ].filter(Boolean)
+
+ return (
+
+ )
+ })}
+
+ ) : (
+
+ Keine Vertretung hinterlegt.
+
+ )}
+
+ )}
)
}
diff --git a/frontend/src/components/OrganizationChart.tsx b/frontend/src/components/OrganizationChart.tsx
index ce3fef9..ecadfe2 100644
--- a/frontend/src/components/OrganizationChart.tsx
+++ b/frontend/src/components/OrganizationChart.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'
import type { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared'
import api from '../services/api'
import { useAuthStore } from '../stores/authStore'
+import { decodeHtmlEntities } from '../utils/text'
interface OrganizationChartProps {
onClose: () => void
@@ -113,6 +114,26 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
return colors[level % colors.length]
}
+ const getUnitDisplayLabel = (unit?: OrganizationalUnit | null) => {
+ if (!unit) return ''
+ const code = unit.code?.trim()
+ if (code) return code
+ const decoded = decodeHtmlEntities(unit.name) || unit.name || ''
+ return decoded.trim()
+ }
+
+ const getUnitDescription = (unit?: OrganizationalUnit | null) => {
+ if (!unit) return ''
+ const source = unit.description && unit.description.trim().length > 0 ? unit.description : unit.name
+ const decoded = decodeHtmlEntities(source) || source || ''
+ const trimmed = decoded.trim()
+ const label = getUnitDisplayLabel(unit)
+ if (trimmed && trimmed !== label) {
+ return trimmed
+ }
+ return ''
+ }
+
const handleUnitClick = (unit: OrganizationalUnit) => {
setSelectedUnit(unit)
loadUnitDetails(unit.id)
@@ -147,7 +168,8 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
const renderUnit = (unit: any, level: number = 0) => {
const isMyUnit = myUnits.some(u => u.id === unit.id)
- const isSearchMatch = searchTerm && unit.name.toLowerCase().includes(searchTerm.toLowerCase())
+ const searchable = `${getUnitDisplayLabel(unit)} ${getUnitDescription(unit)}`.toLowerCase()
+ const isSearchMatch = searchTerm && searchable.includes(searchTerm.toLowerCase())
return (
@@ -167,7 +189,12 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
- {unit.name}
+ {getUnitDisplayLabel(unit)}
+ {getUnitDescription(unit) && (
+
+ {getUnitDescription(unit)}
+
+ )}
{unit.hasFuehrungsstelle && (
FüSt
@@ -313,8 +340,13 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
{selectedUnit && (
- {selectedUnit.name}
- {selectedUnit.code && {selectedUnit.code} }
+ {getUnitDisplayLabel(selectedUnit)}
+ {getUnitDescription(selectedUnit) && (
+
+ Aufgaben
+ {getUnitDescription(selectedUnit)}
+
+ )}
{/* Tabs */}
@@ -365,13 +397,6 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
)}
- {selectedUnit.description && (
-
- Beschreibung
- {selectedUnit.description}
-
- )}
-
{user?.employeeId && (
|