From 859ab6b28d20f86b9c8ea3527e3e8224dff05257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Gr=C3=BCssing?= Date: Wed, 3 Jun 2026 09:29:30 +0200 Subject: [PATCH] Add: Lizenzpreise, Rollen-Picker, Audit-Log Fix, Ticket Benutzerfilter --- backend/src/controllers/entra.controller.js | 25 ++- backend/src/db/seed.js | 31 +++ backend/src/routes/licensePrice.routes.js | 49 +++++ backend/src/server.js | 2 + frontend/src/pages/SettingsPage.jsx | 210 ++++++++++++++++++++ frontend/src/pages/TicketsPage.jsx | 12 +- frontend/src/pages/UserManagementPage.jsx | 74 ++++++- 7 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 backend/src/routes/licensePrice.routes.js diff --git a/backend/src/controllers/entra.controller.js b/backend/src/controllers/entra.controller.js index 1eae0e3..3ff8a3e 100644 --- a/backend/src/controllers/entra.controller.js +++ b/backend/src/controllers/entra.controller.js @@ -15,6 +15,8 @@ const { getConditionalAccessPolicies, invalidateUserSessions, } = require('../services/graph.service'); +const Database = require('better-sqlite3'); +const DB_PATH = process.env.DATABASE_PATH || './database.sqlite'; /** * GET /api/entra/users @@ -154,7 +156,28 @@ exports.getUserLicenses = async (req, res) => { ]); const skuMap = {}; for (const s of skus) skuMap[s.skuId] = s; - const enriched = licenses.map(l => ({ ...l, skuInfo: skuMap[l.skuId] || null })); + + // Preise aus license_prices Tabelle laden + let priceRows = []; + try { + const db = new Database(DB_PATH); + priceRows = db.prepare('SELECT sku_part_number, display_name, price_per_month FROM license_prices').all(); + db.close(); + } catch (_) {} + + const enriched = licenses.map(l => { + const skuPart = l.skuPartNumber || ''; + const skuPartUpper = skuPart.toUpperCase(); + // Exact match zuerst, dann partial match + const priceRow = priceRows.find(p => p.sku_part_number.toUpperCase() === skuPartUpper) + || priceRows.find(p => skuPartUpper.includes(p.sku_part_number.toUpperCase()) || p.sku_part_number.toUpperCase().includes(skuPartUpper)); + return { + ...l, + skuInfo: skuMap[l.skuId] || null, + price_per_month: priceRow?.price_per_month ?? null, + price_display_name: priceRow?.display_name ?? null, + }; + }); res.json({ success: true, data: enriched }); } catch (e) { res.status(500).json({ success: false, message: e.message }); diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index a9425fd..83cbfd0 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -509,6 +509,37 @@ async function initializeDatabase() { )`, `CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON cron_logs(job_name, started_at)`, `ALTER TABLE users ADD COLUMN avatar_url TEXT`, + // Lizenzpreise + `CREATE TABLE IF NOT EXISTS license_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku_part_number TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + price_per_month REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPE_E3', 'Microsoft 365 E3', 36.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPE_E5', 'Microsoft 365 E5', 57.20)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('O365_BUSINESS_PREMIUM', 'Microsoft 365 Business Premium', 22.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('O365_BUSINESS_ESSENTIALS', 'Microsoft 365 Business Basic', 6.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('O365_BUSINESS', 'Microsoft 365 Apps for Business', 8.80)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('ENTERPRISEPREMIUM', 'Office 365 E3', 23.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('ENTERPRISEPACK', 'Office 365 E3', 23.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('MCOEV', 'Microsoft Teams Phone', 8.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('FLOW_FREE', 'Power Automate Free', 0.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('POWER_BI_PRO', 'Power BI Pro', 10.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('POWER_BI_STANDARD', 'Power BI Free', 0.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('INTUNE_A', 'Microsoft Intune', 8.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('AAD_PREMIUM', 'Azure AD Premium P1', 6.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('AAD_PREMIUM_P2', 'Azure AD Premium P2', 9.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('EMS', 'Enterprise Mobility + Security E3', 8.80)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('EMSPREMIUM', 'Enterprise Mobility + Security E5', 14.80)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('VISIOCLIENT', 'Visio Plan 2', 28.10)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('PROJECTCLIENT', 'Project Plan 3', 30.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('WINDOWS_STORE', 'Windows Store', 0.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('MCOPSTN1', 'Teams Domestic Calling', 8.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('Remote_', 'Remote Desktop Services', 0.00)`, + `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPB', 'Microsoft 365 Business Premium', 22.00)`, ]; for (const migration of migrations) { try { diff --git a/backend/src/routes/licensePrice.routes.js b/backend/src/routes/licensePrice.routes.js new file mode 100644 index 0000000..9dd9c56 --- /dev/null +++ b/backend/src/routes/licensePrice.routes.js @@ -0,0 +1,49 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateToken } = require('../middleware/auth'); +const { requireAdmin } = require('../middleware/roleCheck'); +const { asyncHandler } = require('../middleware/errorHandler'); +const Database = require('better-sqlite3'); +const DB_PATH = process.env.DATABASE_PATH || './database.sqlite'; + +router.use(authenticateToken); + +// GET alle Preise +router.get('/', asyncHandler(async (req, res) => { + const db = new Database(DB_PATH); + const prices = db.prepare('SELECT * FROM license_prices ORDER BY display_name').all(); + db.close(); + res.json({ status: 'success', data: prices }); +})); + +// PUT Preis aktualisieren +router.put('/:id', requireAdmin, asyncHandler(async (req, res) => { + const { price_per_month, display_name } = req.body; + const db = new Database(DB_PATH); + db.prepare('UPDATE license_prices SET price_per_month=?, display_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?') + .run(parseFloat(price_per_month) || 0, display_name, parseInt(req.params.id)); + const updated = db.prepare('SELECT * FROM license_prices WHERE id=?').get(parseInt(req.params.id)); + db.close(); + res.json({ status: 'success', data: updated }); +})); + +// POST neuer Preis +router.post('/', requireAdmin, asyncHandler(async (req, res) => { + const { sku_part_number, display_name, price_per_month } = req.body; + const db = new Database(DB_PATH); + const result = db.prepare('INSERT OR REPLACE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES (?,?,?)') + .run(sku_part_number, display_name, parseFloat(price_per_month) || 0); + const created = db.prepare('SELECT * FROM license_prices WHERE id=?').get(result.lastInsertRowid); + db.close(); + res.json({ status: 'success', data: created }); +})); + +// DELETE +router.delete('/:id', requireAdmin, asyncHandler(async (req, res) => { + const db = new Database(DB_PATH); + db.prepare('DELETE FROM license_prices WHERE id=?').run(parseInt(req.params.id)); + db.close(); + res.json({ status: 'success' }); +})); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index e561c43..b6a31ba 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -179,6 +179,8 @@ const feedbackRoutes = require('./routes/feedback.routes'); app.use('/api/feedback', feedbackRoutes); const cronRoutes = require('./routes/cron.routes'); app.use('/api/cron', cronRoutes); +const licensePriceRoutes = require('./routes/licensePrice.routes'); +app.use('/api/license-prices', licensePriceRoutes); // Static file serving for uploads (PDFs) const path = require('path'); diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index d71fefd..5d5fa40 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -29,6 +29,14 @@ const COMMON_ICONS = ['πŸ“‹', 'πŸ’»', 'πŸ–₯️', '🌐', 'πŸ“Š', 'πŸ–¨οΈ', ' export default function SettingsPage() { const [tab, setTab] = useState('categories'); + // ── License prices state ────────────────────────────────────────────────── + const [licPrices, setLicPrices] = useState([]); + const [licLoading, setLicLoading] = useState(false); + const [licEditId, setLicEditId] = useState(null); + const [licEditRow, setLicEditRow] = useState({}); + const [licNewForm, setLicNewForm] = useState(null); // null | { sku_part_number, display_name, price_per_month } + const [licSaving, setLicSaving] = useState(false); + // ── Categories state ────────────────────────────────────────────────────── const [categories, setCategories] = useState([]); const [catModal, setCatModal] = useState(null); // null | { id?, name, icon } @@ -78,8 +86,24 @@ export default function SettingsPage() { setDesignDraft(res.data); }, []); + const loadLicPrices = useCallback(async () => { + setLicLoading(true); + try { + const apiUrl = process.env.REACT_APP_API_URL || '/api'; + const token = localStorage.getItem('token'); + const res = await fetch(`${apiUrl}/license-prices`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const json = await res.json(); + setLicPrices(json.data || []); + } catch (_) {} + setLicLoading(false); + }, []); + useEffect(() => { loadCategories(); loadTemplates(); loadEmailTpls(); loadEmailDesign(); }, [loadCategories, loadTemplates, loadEmailTpls, loadEmailDesign]); + useEffect(() => { if (tab === 'license-prices') loadLicPrices(); }, [tab, loadLicPrices]); + // Fetch preview when designDraft or previewType changes useEffect(() => { if (!designDraft) return; @@ -184,6 +208,49 @@ export default function SettingsPage() { const setDraft = (key, val) => setDesignDraft(d => ({ ...d, [key]: val })); + // ── License price CRUD ──────────────────────────────────────────────────── + const licAuthFetch = (url, opts = {}) => { + const token = localStorage.getItem('token'); + const apiUrl = process.env.REACT_APP_API_URL || '/api'; + return fetch(`${apiUrl}${url}`, { + ...opts, + headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + }); + }; + + const saveLicEdit = async () => { + setLicSaving(true); + try { + await licAuthFetch(`/license-prices/${licEditId}`, { + method: 'PUT', + body: JSON.stringify(licEditRow), + }); + setLicEditId(null); + await loadLicPrices(); + } catch (_) { alert('Fehler beim Speichern'); } + setLicSaving(false); + }; + + const saveLicNew = async () => { + if (!licNewForm?.sku_part_number?.trim() || !licNewForm?.display_name?.trim()) return; + setLicSaving(true); + try { + await licAuthFetch('/license-prices', { + method: 'POST', + body: JSON.stringify(licNewForm), + }); + setLicNewForm(null); + await loadLicPrices(); + } catch (_) { alert('Fehler beim Erstellen'); } + setLicSaving(false); + }; + + const deleteLicPrice = async (id, name) => { + if (!window.confirm(`Lizenzpreis β€ž${name}" wirklich lΓΆschen?`)) return; + await licAuthFetch(`/license-prices/${id}`, { method: 'DELETE' }); + await loadLicPrices(); + }; + // ── Email template CRUD ─────────────────────────────────────────────────── const saveEmailTpl = async () => { @@ -313,6 +380,7 @@ export default function SettingsPage() { { key: 'email-templates', label: 'πŸ“§ E-Mail-Vorlagen' }, { key: 'email-design', label: '🎨 E-Mail-Design' }, { key: 'cronjobs', label: '⏰ Cronjobs' }, + { key: 'license-prices', label: 'πŸ’° Lizenzpreise' }, ].map(t => ( + + + {/* Neues Lizenz-Formular */} + {licNewForm && ( +
+
βž• Neue Lizenz
+
+
+ + setLicNewForm(f => ({ ...f, sku_part_number: e.target.value }))} + placeholder="z.B. SPE_E3" + style={{ fontSize: '13px' }} + /> +
+
+ + setLicNewForm(f => ({ ...f, display_name: e.target.value }))} + placeholder="z.B. Microsoft 365 E3" + style={{ fontSize: '13px' }} + /> +
+
+ + setLicNewForm(f => ({ ...f, price_per_month: e.target.value }))} + placeholder="0.00" + style={{ fontSize: '13px' }} + /> +
+
+ + +
+
+
+ )} + +
+ {licLoading ? ( +

LΓ€dt…

+ ) : licPrices.length === 0 ? ( +

Keine Lizenzpreise vorhanden

+ ) : ( + + + + + + + + + + + {licPrices.map(p => ( + + {licEditId === p.id ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + + ))} + +
SKUNamePreis/Monat
+ setLicEditRow(r => ({ ...r, sku_part_number: e.target.value }))} + style={{ fontSize: '12px', padding: '4px 8px' }} + /> + + setLicEditRow(r => ({ ...r, display_name: e.target.value }))} + style={{ fontSize: '12px', padding: '4px 8px', width: '100%' }} + /> + + setLicEditRow(r => ({ ...r, price_per_month: e.target.value }))} + style={{ fontSize: '12px', padding: '4px 8px', textAlign: 'right' }} + /> + + + + {p.sku_part_number}{p.display_name} 0 ? 'var(--success, #10b981)' : 'var(--text-muted)', fontWeight: 600 }}> + {p.price_per_month > 0 + ? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.price_per_month) + : 'Kostenlos'} + + + +
+ )} +
+ + )} + {/* end constrained tab content */} {/* ── EMAIL DESIGN TAB – full width ─────────────────────────────── */} diff --git a/frontend/src/pages/TicketsPage.jsx b/frontend/src/pages/TicketsPage.jsx index f5d3eb0..40c95f7 100644 --- a/frontend/src/pages/TicketsPage.jsx +++ b/frontend/src/pages/TicketsPage.jsx @@ -199,7 +199,17 @@ const TicketsPage = () => { useEffect(() => { loadAll(); }, [statusFilter, priorityFilter, categoryFilter, searchTerm, userFilter]); useEffect(() => { - userService.getAll().then(setAllUsers).catch(() => {}); + // Lade alle offenen Tickets um zu sehen wer aktive Tickets hat + Promise.all([ + userService.getAll(), + ticketService.getAll({ status: 'offen' }), + ticketService.getAll({ status: 'in_bearbeitung' }), + ]).then(([users, offen, inBearbeitung]) => { + const activeTickets = [...offen, ...inBearbeitung]; + const activeUserIds = new Set(activeTickets.map(t => t.assigned_to_user_id).filter(Boolean)); + const staffRoles = ['super_admin', 'admin', 'support', 'bearbeiter']; + setAllUsers(users.filter(u => staffRoles.includes(u.role_name) && activeUserIds.has(u.id))); + }).catch(() => {}); }, []); // Auto-refresh alle 30 Sekunden diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx index 4bd48c8..46ccffa 100644 --- a/frontend/src/pages/UserManagementPage.jsx +++ b/frontend/src/pages/UserManagementPage.jsx @@ -489,6 +489,10 @@ const LicensesTab = ({ user }) => { return g; }, [licenses]); + const totalCost = useMemo(() => { + return licenses.reduce((sum, lic) => sum + (lic.price_per_month ?? 0), 0); + }, [licenses]); + const getLicDisplay = (lic) => { const key = Object.keys(SKU_DISPLAY).find(k => { const part = k.includes(':') ? k.split(':')[1] : k; @@ -512,8 +516,8 @@ const LicensesTab = ({ user }) => {
Kosten pro Monat
-
β€”
-
Preise in Entra konfigurierbar
+
{totalCost > 0 ? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalCost) : 'β€”'}
+
{totalCost > 0 ? 'Basierend auf konfigurierten Preisen' : 'Preise unter Einstellungen konfigurieren'}
VerlΓ€ngerung fΓ€llig
@@ -539,7 +543,11 @@ const LicensesTab = ({ user }) => {
{lic.skuInfo?.capabilityStatus || 'Enabled'}
-
β€”
+
+ {lic.price_per_month != null + ? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(lic.price_per_month) + : 'β€”'} +
/Monat
@@ -922,7 +930,18 @@ const LifecycleTab = ({ user, full }) => { /* ══════════════════════════════════════════════════════════════════ Edit Modal ══════════════════════════════════════════════════════════════════ */ -const EditModal = ({ user, allUsers, onClose, onSaved }) => { +const ROLE_PICKER = [ + { name: 'super_admin', label: 'Super Admin', desc: 'Vollzugriff inkl. Benutzerverwaltung', icon: 'πŸ‘‘', color: '#b91c1c' }, + { name: 'admin', label: 'Admin', desc: 'Administrator - Kann Benutzer & Assets verwalten', icon: 'πŸ›‘οΈ', color: '#dc2626' }, + { name: 'bearbeiter', label: 'Bearbeiter', desc: 'Editor - Kann Assets erstellen und bearbeiten', icon: 'πŸ”§', color: '#059669' }, + { name: 'benutzer', label: 'Benutzer', desc: 'Standard - Nur Lesezugriff', icon: 'πŸ‘€', color: '#475569' }, + { name: 'support', label: 'IT-Support', desc: 'Support - Kann Tickets sehen und bearbeiten', icon: '🎧', color: '#2563eb' }, + { name: 'hr_personal', label: 'HR / Personal', desc: 'HR / Personal - Sieht HR-Aufgaben im Onboarding', icon: 'πŸ‘₯', color: '#7c3aed' }, + { name: 'buchhaltung', label: 'Buchhaltung', desc: 'Buchhaltung / Lohn - Sieht Buchhaltungs-Aufgaben', icon: 'πŸ’°', color: '#0891b2' }, + { name: 'produktion', label: 'Produktion', desc: 'Produktion - Sieht und verwaltet Produktions-Assets', icon: '🏭', color: '#d97706' }, +]; + +const EditModal = ({ user, allUsers, roles, onClose, onSaved }) => { const [form, setForm] = useState({ first_name: user.first_name || '', last_name: user.last_name || '', @@ -936,16 +955,29 @@ const EditModal = ({ user, allUsers, onClose, onSaved }) => { employment_type: user.employment_type || 'Vollzeit', work_hours: user.work_hours || 40, joined_date: user.joined_date ? user.joined_date.split('T')[0] : '', + role_name: user.role_name || 'benutzer', + role_id: user.role_id || '', }); const [saving, setSaving] = useState(false); + const [allRoles, setAllRoles] = useState([]); + useEffect(() => { + authFetch(`${API}/users/roles`).then(r => r.json()).then(d => setAllRoles(d.data || [])).catch(() => {}); + }, []); const save = async () => { setSaving(true); try { - const r = await authFetch(`${API}/users/${user.id}`, { method: 'PUT', body: JSON.stringify(form) }); + // Finde role_id basierend auf role_name + const selectedRole = allRoles.find(r => r.name === form.role_name); + const saveData = { ...form, role_id: selectedRole?.id || form.role_id }; + const r = await authFetch(`${API}/users/${user.id}`, { method: 'PUT', body: JSON.stringify(saveData) }); if (!r.ok) throw new Error('Fehler beim Speichern'); + // Rolle separat zuweisen falls geΓ€ndert + if (selectedRole && selectedRole.id !== user.role_id) { + await authFetch(`${API}/users/${user.id}/role`, { method: 'PUT', body: JSON.stringify({ role_id: selectedRole.id }) }); + } toast.success('Gespeichert'); - onSaved({ ...user, ...form }); + onSaved({ ...user, ...saveData, role_name: form.role_name }); onClose(); } catch { toast.error('Fehler beim Speichern'); } finally { setSaving(false); } @@ -993,6 +1025,34 @@ const EditModal = ({ user, allUsers, onClose, onSaved }) => { + {/* Rollen-Picker */} +
+ +
+ {ROLE_PICKER.map(role => { + const active = form.role_name === role.name; + return ( +
setForm(p => ({...p, role_name: role.name}))} + style={{ + padding:'12px 14px', borderRadius:10, cursor:'pointer', + border: active ? `2px solid ${role.color}` : '1px solid rgba(255,255,255,0.08)', + background: active ? `${role.color}18` : 'rgba(255,255,255,0.03)', + display:'flex', alignItems:'flex-start', gap:10, + transition:'all 0.15s', + }}> + {role.icon} +
+
+ {role.label} + {active && βœ“} +
+
{role.desc}
+
+
+ ); + })} +
+
@@ -1069,7 +1129,7 @@ const UserManagementPage = () => { setAuditLogs([]); Promise.all([ authFetch(`${API}/users/${selectedId}/full`).then(r => r.json()).catch(() => ({})), - authFetch(`${API}/audit-logs/user/${selectedId}?limit=10`).then(r => r.json()).catch(() => ({})), + authFetch(`${API}/users/audit-logs/user/${selectedId}?limit=10`).then(r => r.json()).catch(() => ({})), ]).then(([fRes, lRes]) => { setFull(fRes.data || null); setAuditLogs(lRes.data || []);