Add: Lizenzpreise, Rollen-Picker, Audit-Log Fix, Ticket Benutzerfilter
This commit is contained in:
@@ -15,6 +15,8 @@ const {
|
|||||||
getConditionalAccessPolicies,
|
getConditionalAccessPolicies,
|
||||||
invalidateUserSessions,
|
invalidateUserSessions,
|
||||||
} = require('../services/graph.service');
|
} = require('../services/graph.service');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/entra/users
|
* GET /api/entra/users
|
||||||
@@ -154,7 +156,28 @@ exports.getUserLicenses = async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
const skuMap = {};
|
const skuMap = {};
|
||||||
for (const s of skus) skuMap[s.skuId] = s;
|
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 });
|
res.json({ success: true, data: enriched });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ success: false, message: e.message });
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
|||||||
@@ -509,6 +509,37 @@ async function initializeDatabase() {
|
|||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON cron_logs(job_name, started_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON cron_logs(job_name, started_at)`,
|
||||||
`ALTER TABLE users ADD COLUMN avatar_url TEXT`,
|
`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) {
|
for (const migration of migrations) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
49
backend/src/routes/licensePrice.routes.js
Normal file
49
backend/src/routes/licensePrice.routes.js
Normal file
@@ -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;
|
||||||
@@ -179,6 +179,8 @@ const feedbackRoutes = require('./routes/feedback.routes');
|
|||||||
app.use('/api/feedback', feedbackRoutes);
|
app.use('/api/feedback', feedbackRoutes);
|
||||||
const cronRoutes = require('./routes/cron.routes');
|
const cronRoutes = require('./routes/cron.routes');
|
||||||
app.use('/api/cron', cronRoutes);
|
app.use('/api/cron', cronRoutes);
|
||||||
|
const licensePriceRoutes = require('./routes/licensePrice.routes');
|
||||||
|
app.use('/api/license-prices', licensePriceRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (PDFs)
|
// Static file serving for uploads (PDFs)
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ const COMMON_ICONS = ['📋', '💻', '🖥️', '🌐', '📊', '🖨️', '
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [tab, setTab] = useState('categories');
|
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 ──────────────────────────────────────────────────────
|
// ── Categories state ──────────────────────────────────────────────────────
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [catModal, setCatModal] = useState(null); // null | { id?, name, icon }
|
const [catModal, setCatModal] = useState(null); // null | { id?, name, icon }
|
||||||
@@ -78,8 +86,24 @@ export default function SettingsPage() {
|
|||||||
setDesignDraft(res.data);
|
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(() => { loadCategories(); loadTemplates(); loadEmailTpls(); loadEmailDesign(); }, [loadCategories, loadTemplates, loadEmailTpls, loadEmailDesign]);
|
||||||
|
|
||||||
|
useEffect(() => { if (tab === 'license-prices') loadLicPrices(); }, [tab, loadLicPrices]);
|
||||||
|
|
||||||
// Fetch preview when designDraft or previewType changes
|
// Fetch preview when designDraft or previewType changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!designDraft) return;
|
if (!designDraft) return;
|
||||||
@@ -184,6 +208,49 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const setDraft = (key, val) => setDesignDraft(d => ({ ...d, [key]: val }));
|
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 ───────────────────────────────────────────────────
|
// ── Email template CRUD ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const saveEmailTpl = async () => {
|
const saveEmailTpl = async () => {
|
||||||
@@ -313,6 +380,7 @@ export default function SettingsPage() {
|
|||||||
{ key: 'email-templates', label: '📧 E-Mail-Vorlagen' },
|
{ key: 'email-templates', label: '📧 E-Mail-Vorlagen' },
|
||||||
{ key: 'email-design', label: '🎨 E-Mail-Design' },
|
{ key: 'email-design', label: '🎨 E-Mail-Design' },
|
||||||
{ key: 'cronjobs', label: '⏰ Cronjobs' },
|
{ key: 'cronjobs', label: '⏰ Cronjobs' },
|
||||||
|
{ key: 'license-prices', label: '💰 Lizenzpreise' },
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@@ -435,6 +503,148 @@ export default function SettingsPage() {
|
|||||||
{/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
|
{/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
|
||||||
{tab === 'cronjobs' && <CronJobsTab />}
|
{tab === 'cronjobs' && <CronJobsTab />}
|
||||||
|
|
||||||
|
{/* ── LICENSE PRICES TAB ───────────────────────────────────────── */}
|
||||||
|
{tab === 'license-prices' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
|
||||||
|
{licPrices.length} Einträge · Preise werden in der Benutzerverwaltung bei Lizenzen angezeigt
|
||||||
|
</p>
|
||||||
|
<button style={btnPrimary} onClick={() => setLicNewForm({ sku_part_number: '', display_name: '', price_per_month: '' })}>
|
||||||
|
+ Neue Lizenz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neues Lizenz-Formular */}
|
||||||
|
{licNewForm && (
|
||||||
|
<div style={{ ...card, background: 'var(--bg-secondary)', marginBottom: '16px', border: '1px solid var(--cereda-primary)' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '14px', color: 'var(--text-primary)', marginBottom: '12px' }}>➕ Neue Lizenz</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr 120px auto', gap: '10px', alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--text-muted)', display: 'block', marginBottom: '4px' }}>SKU Part Number*</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={licNewForm.sku_part_number}
|
||||||
|
onChange={e => setLicNewForm(f => ({ ...f, sku_part_number: e.target.value }))}
|
||||||
|
placeholder="z.B. SPE_E3"
|
||||||
|
style={{ fontSize: '13px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--text-muted)', display: 'block', marginBottom: '4px' }}>Anzeigename*</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={licNewForm.display_name}
|
||||||
|
onChange={e => setLicNewForm(f => ({ ...f, display_name: e.target.value }))}
|
||||||
|
placeholder="z.B. Microsoft 365 E3"
|
||||||
|
style={{ fontSize: '13px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--text-muted)', display: 'block', marginBottom: '4px' }}>€/Monat</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={licNewForm.price_per_month}
|
||||||
|
onChange={e => setLicNewForm(f => ({ ...f, price_per_month: e.target.value }))}
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{ fontSize: '13px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={btnPrimary} onClick={saveLicNew} disabled={licSaving || !licNewForm.sku_part_number?.trim() || !licNewForm.display_name?.trim()}>
|
||||||
|
{licSaving ? '…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button style={btnOutline} onClick={() => setLicNewForm(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={card}>
|
||||||
|
{licLoading ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Lädt…</p>
|
||||||
|
) : licPrices.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Keine Lizenzpreise vorhanden</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px 10px', color: 'var(--text-muted)', fontWeight: 600, fontSize: '12px' }}>SKU</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px 10px', color: 'var(--text-muted)', fontWeight: 600, fontSize: '12px' }}>Name</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 10px', color: 'var(--text-muted)', fontWeight: 600, fontSize: '12px' }}>Preis/Monat</th>
|
||||||
|
<th style={{ padding: '8px 10px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{licPrices.map(p => (
|
||||||
|
<tr key={p.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
{licEditId === p.id ? (
|
||||||
|
<>
|
||||||
|
<td style={{ padding: '6px 10px' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={licEditRow.sku_part_number ?? p.sku_part_number}
|
||||||
|
onChange={e => setLicEditRow(r => ({ ...r, sku_part_number: e.target.value }))}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={licEditRow.display_name ?? p.display_name}
|
||||||
|
onChange={e => setLicEditRow(r => ({ ...r, display_name: e.target.value }))}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={licEditRow.price_per_month ?? p.price_per_month}
|
||||||
|
onChange={e => setLicEditRow(r => ({ ...r, price_per_month: e.target.value }))}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px', textAlign: 'right' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button style={{ ...btnPrimary, padding: '4px 10px', fontSize: '12px', marginRight: '6px' }} onClick={saveLicEdit} disabled={licSaving}>
|
||||||
|
{licSaving ? '…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button style={{ ...btnOutline, padding: '4px 10px', fontSize: '12px' }} onClick={() => setLicEditId(null)}>Abbrechen</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td style={{ padding: '8px 10px', color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: '12px' }}>{p.sku_part_number}</td>
|
||||||
|
<td style={{ padding: '8px 10px', color: 'var(--text-primary)', fontWeight: 500 }}>{p.display_name}</td>
|
||||||
|
<td style={{ padding: '8px 10px', textAlign: 'right', color: p.price_per_month > 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'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 10px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button style={{ ...btnOutline, padding: '4px 10px', fontSize: '12px', marginRight: '6px' }} onClick={() => { setLicEditId(p.id); setLicEditRow({ display_name: p.display_name, price_per_month: p.price_per_month, sku_part_number: p.sku_part_number }); }}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button style={{ ...btnDanger, padding: '4px 10px', fontSize: '12px' }} onClick={() => deleteLicPrice(p.id, p.display_name)}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>{/* end constrained tab content */}
|
</div>{/* end constrained tab content */}
|
||||||
|
|
||||||
{/* ── EMAIL DESIGN TAB – full width ─────────────────────────────── */}
|
{/* ── EMAIL DESIGN TAB – full width ─────────────────────────────── */}
|
||||||
|
|||||||
@@ -199,7 +199,17 @@ const TicketsPage = () => {
|
|||||||
|
|
||||||
useEffect(() => { loadAll(); }, [statusFilter, priorityFilter, categoryFilter, searchTerm, userFilter]);
|
useEffect(() => { loadAll(); }, [statusFilter, priorityFilter, categoryFilter, searchTerm, userFilter]);
|
||||||
useEffect(() => {
|
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
|
// Auto-refresh alle 30 Sekunden
|
||||||
|
|||||||
@@ -489,6 +489,10 @@ const LicensesTab = ({ user }) => {
|
|||||||
return g;
|
return g;
|
||||||
}, [licenses]);
|
}, [licenses]);
|
||||||
|
|
||||||
|
const totalCost = useMemo(() => {
|
||||||
|
return licenses.reduce((sum, lic) => sum + (lic.price_per_month ?? 0), 0);
|
||||||
|
}, [licenses]);
|
||||||
|
|
||||||
const getLicDisplay = (lic) => {
|
const getLicDisplay = (lic) => {
|
||||||
const key = Object.keys(SKU_DISPLAY).find(k => {
|
const key = Object.keys(SKU_DISPLAY).find(k => {
|
||||||
const part = k.includes(':') ? k.split(':')[1] : k;
|
const part = k.includes(':') ? k.split(':')[1] : k;
|
||||||
@@ -512,8 +516,8 @@ const LicensesTab = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bv-lic-sum-item">
|
<div className="bv-lic-sum-item">
|
||||||
<div className="bv-lic-sum-label">Kosten pro Monat</div>
|
<div className="bv-lic-sum-label">Kosten pro Monat</div>
|
||||||
<div className="bv-lic-sum-value">—</div>
|
<div className="bv-lic-sum-value">{totalCost > 0 ? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalCost) : '—'}</div>
|
||||||
<div className="bv-lic-sum-sub">Preise in Entra konfigurierbar</div>
|
<div className="bv-lic-sum-sub">{totalCost > 0 ? 'Basierend auf konfigurierten Preisen' : 'Preise unter Einstellungen konfigurieren'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bv-lic-sum-item">
|
<div className="bv-lic-sum-item">
|
||||||
<div className="bv-lic-sum-label">Verlängerung fällig</div>
|
<div className="bv-lic-sum-label">Verlängerung fällig</div>
|
||||||
@@ -539,7 +543,11 @@ const LicensesTab = ({ user }) => {
|
|||||||
<div className="bv-lic-desc">{lic.skuInfo?.capabilityStatus || 'Enabled'}</div>
|
<div className="bv-lic-desc">{lic.skuInfo?.capabilityStatus || 'Enabled'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="bv-lic-price">—</div>
|
<div className="bv-lic-price">
|
||||||
|
{lic.price_per_month != null
|
||||||
|
? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(lic.price_per_month)
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
<div className="bv-lic-per">/Monat</div>
|
<div className="bv-lic-per">/Monat</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -922,7 +930,18 @@ const LifecycleTab = ({ user, full }) => {
|
|||||||
/* ══════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
Edit Modal
|
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({
|
const [form, setForm] = useState({
|
||||||
first_name: user.first_name || '',
|
first_name: user.first_name || '',
|
||||||
last_name: user.last_name || '',
|
last_name: user.last_name || '',
|
||||||
@@ -936,16 +955,29 @@ const EditModal = ({ user, allUsers, onClose, onSaved }) => {
|
|||||||
employment_type: user.employment_type || 'Vollzeit',
|
employment_type: user.employment_type || 'Vollzeit',
|
||||||
work_hours: user.work_hours || 40,
|
work_hours: user.work_hours || 40,
|
||||||
joined_date: user.joined_date ? user.joined_date.split('T')[0] : '',
|
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 [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 () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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');
|
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');
|
toast.success('Gespeichert');
|
||||||
onSaved({ ...user, ...form });
|
onSaved({ ...user, ...saveData, role_name: form.role_name });
|
||||||
onClose();
|
onClose();
|
||||||
} catch { toast.error('Fehler beim Speichern'); }
|
} catch { toast.error('Fehler beim Speichern'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
@@ -993,6 +1025,34 @@ const EditModal = ({ user, allUsers, onClose, onSaved }) => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Rollen-Picker */}
|
||||||
|
<div style={{marginTop:16}}>
|
||||||
|
<label className="bv-form-label" style={{display:'block',marginBottom:8,fontSize:13,color:'#94a3b8'}}>Rolle *</label>
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
|
||||||
|
{ROLE_PICKER.map(role => {
|
||||||
|
const active = form.role_name === role.name;
|
||||||
|
return (
|
||||||
|
<div key={role.name} onClick={() => 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',
|
||||||
|
}}>
|
||||||
|
<span style={{fontSize:20,lineHeight:1}}>{role.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{fontWeight:600,fontSize:13,color: active ? role.color : '#f1f5f9',display:'flex',alignItems:'center',gap:6}}>
|
||||||
|
{role.label}
|
||||||
|
{active && <span style={{fontSize:10,color:role.color}}>✓</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:11,color:'#64748b',marginTop:2}}>{role.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bv-form-actions">
|
<div className="bv-form-actions">
|
||||||
<button className="btn btn-outline" onClick={onClose}>Abbrechen</button>
|
<button className="btn btn-outline" onClick={onClose}>Abbrechen</button>
|
||||||
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Speichern…' : 'Speichern'}</button>
|
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Speichern…' : 'Speichern'}</button>
|
||||||
@@ -1069,7 +1129,7 @@ const UserManagementPage = () => {
|
|||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
authFetch(`${API}/users/${selectedId}/full`).then(r => r.json()).catch(() => ({})),
|
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]) => {
|
]).then(([fRes, lRes]) => {
|
||||||
setFull(fRes.data || null);
|
setFull(fRes.data || null);
|
||||||
setAuditLogs(lRes.data || []);
|
setAuditLogs(lRes.data || []);
|
||||||
|
|||||||
Reference in New Issue
Block a user