902 lines
50 KiB
React
902 lines
50 KiB
React
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||
|
|
import api from '../services/api';
|
|||
|
|
import assetService from '../services/assetService';
|
|||
|
|
|
|||
|
|
const EMAIL_TEMPLATE_LABELS = {
|
|||
|
|
comment_added: { label: '💬 Neuer Kommentar', desc: 'Gesendet an den Ersteller wenn Support antwortet' },
|
|||
|
|
escalation: { label: '⚠️ Eskalation', desc: 'An Admins bei überfälligen kritischen/hohen Tickets' },
|
|||
|
|
satisfaction: { label: '⭐ Zufriedenheits-Feedback', desc: 'Gesendet nach Ticketschließung' },
|
|||
|
|
status_changed: { label: '🔄 Statusänderung', desc: 'An den Ersteller bei Statuswechsel' },
|
|||
|
|
ticket_assigned: { label: '👤 Ticket-Zuweisung', desc: 'An den Bearbeiter bei Ticket-Zuweisung' },
|
|||
|
|
ticket_created: { label: '✅ Ticket-Bestätigung', desc: 'An den Ersteller nach Ticket-Erstellung' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const EMAIL_TEMPLATE_VARS = {
|
|||
|
|
ticket_created: ['ticket_number', 'ticket_title', 'requester_name', 'category', 'priority'],
|
|||
|
|
ticket_assigned: ['ticket_number', 'ticket_title', 'assignee_name', 'requester_name', 'requester_email', 'category', 'priority'],
|
|||
|
|
comment_added: ['ticket_number', 'ticket_title', 'author_name'],
|
|||
|
|
status_changed: ['ticket_number', 'ticket_title', 'old_status', 'new_status'],
|
|||
|
|
escalation: ['ticket_number', 'ticket_title', 'priority', 'requester_name', 'age_hours'],
|
|||
|
|
satisfaction: ['ticket_number', 'ticket_title', 'requester_name'],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PRIORITIES = ['niedrig', 'mittel', 'hoch', 'kritisch'];
|
|||
|
|
const PRIORITY_ICONS = { niedrig: '🟢', mittel: '🔵', hoch: '🟠', kritisch: '🔴' };
|
|||
|
|
|
|||
|
|
const COMMON_ICONS = ['📋', '💻', '🖥️', '🌐', '📊', '🖨️', '📧', '🔐', '📱', '🔧', '⚡', '🏢', '👤', '🗂️', '🔑'];
|
|||
|
|
|
|||
|
|
export default function SettingsPage() {
|
|||
|
|
const [tab, setTab] = useState('categories');
|
|||
|
|
|
|||
|
|
// ── Categories state ──────────────────────────────────────────────────────
|
|||
|
|
const [categories, setCategories] = useState([]);
|
|||
|
|
const [catModal, setCatModal] = useState(null); // null | { id?, name, icon }
|
|||
|
|
const [catLoading, setCatLoading] = useState(false);
|
|||
|
|
|
|||
|
|
// ── Templates state ───────────────────────────────────────────────────────
|
|||
|
|
const [templates, setTemplates] = useState([]);
|
|||
|
|
const [tplModal, setTplModal] = useState(null); // null | template object
|
|||
|
|
const [tplLoading, setTplLoading] = useState(false);
|
|||
|
|
|
|||
|
|
// ── Email templates state ─────────────────────────────────────────────────
|
|||
|
|
const [emailTpls, setEmailTpls] = useState([]);
|
|||
|
|
const [emailModal, setEmailModal] = useState(null); // null | { type, label, subject, intro }
|
|||
|
|
const [emailLoading, setEmailLoading] = useState(false);
|
|||
|
|
const [lastFocused, setLastFocused] = useState('intro');
|
|||
|
|
const emailSubjectRef = useRef(null);
|
|||
|
|
const emailIntroRef = useRef(null);
|
|||
|
|
|
|||
|
|
// ── Email design state ────────────────────────────────────────────────────
|
|||
|
|
const [emailDesign, setEmailDesign] = useState(null);
|
|||
|
|
const [designDraft, setDesignDraft] = useState(null);
|
|||
|
|
const [designSaving, setDesignSaving] = useState(false);
|
|||
|
|
const [previewType, setPreviewType] = useState('ticket_created');
|
|||
|
|
const [previewDark, setPreviewDark] = useState(false);
|
|||
|
|
const [previewHtml, setPreviewHtml] = useState('');
|
|||
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|||
|
|
const previewDebounce = useRef(null);
|
|||
|
|
|
|||
|
|
const loadCategories = useCallback(async () => {
|
|||
|
|
const res = await api.get('/settings/categories');
|
|||
|
|
setCategories(res.data);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const loadTemplates = useCallback(async () => {
|
|||
|
|
const res = await api.get('/settings/templates');
|
|||
|
|
setTemplates(res.data);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const loadEmailTpls = useCallback(async () => {
|
|||
|
|
const res = await api.get('/settings/email-templates');
|
|||
|
|
setEmailTpls(res.data);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const loadEmailDesign = useCallback(async () => {
|
|||
|
|
const res = await api.get('/settings/email-design');
|
|||
|
|
setEmailDesign(res.data);
|
|||
|
|
setDesignDraft(res.data);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => { loadCategories(); loadTemplates(); loadEmailTpls(); loadEmailDesign(); }, [loadCategories, loadTemplates, loadEmailTpls, loadEmailDesign]);
|
|||
|
|
|
|||
|
|
// Fetch preview when designDraft or previewType changes
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!designDraft) return;
|
|||
|
|
clearTimeout(previewDebounce.current);
|
|||
|
|
previewDebounce.current = setTimeout(async () => {
|
|||
|
|
setPreviewLoading(true);
|
|||
|
|
try {
|
|||
|
|
const apiUrl = process.env.REACT_APP_API_URL || '/api';
|
|||
|
|
const token = localStorage.getItem('token');
|
|||
|
|
const res = await fetch(`${apiUrl}/settings/email-preview/${previewType}`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ design: designDraft, darkMode: previewDark }),
|
|||
|
|
});
|
|||
|
|
const html = await res.text();
|
|||
|
|
setPreviewHtml(html);
|
|||
|
|
} catch (_) {}
|
|||
|
|
setPreviewLoading(false);
|
|||
|
|
}, 600);
|
|||
|
|
}, [designDraft, previewType, previewDark]); // eslint-disable-line
|
|||
|
|
|
|||
|
|
// ── Category CRUD ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const saveCategory = async () => {
|
|||
|
|
if (!catModal?.name?.trim()) return;
|
|||
|
|
setCatLoading(true);
|
|||
|
|
try {
|
|||
|
|
if (catModal.id) {
|
|||
|
|
await api.put(`/settings/categories/${catModal.id}`, { name: catModal.name, icon: catModal.icon });
|
|||
|
|
} else {
|
|||
|
|
await api.post('/settings/categories', { name: catModal.name, icon: catModal.icon });
|
|||
|
|
}
|
|||
|
|
await loadCategories();
|
|||
|
|
setCatModal(null);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setCatLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const deleteCategory = async (cat) => {
|
|||
|
|
if (!window.confirm(`Kategorie „${cat.name}" wirklich löschen?`)) return;
|
|||
|
|
await api.delete(`/settings/categories/${cat.id}`);
|
|||
|
|
await loadCategories();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Template CRUD ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const saveTemplate = async () => {
|
|||
|
|
if (!tplModal?.label?.trim()) return;
|
|||
|
|
setTplLoading(true);
|
|||
|
|
try {
|
|||
|
|
if (tplModal.id) {
|
|||
|
|
await api.put(`/settings/templates/${tplModal.id}`, tplModal);
|
|||
|
|
} else {
|
|||
|
|
await api.post('/settings/templates', tplModal);
|
|||
|
|
}
|
|||
|
|
await loadTemplates();
|
|||
|
|
setTplModal(null);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setTplLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const deleteTemplate = async (tpl) => {
|
|||
|
|
if (!window.confirm(`Vorlage „${tpl.label}" wirklich löschen?`)) return;
|
|||
|
|
await api.delete(`/settings/templates/${tpl.id}`);
|
|||
|
|
await loadTemplates();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Email design CRUD ─────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const saveDesign = async () => {
|
|||
|
|
setDesignSaving(true);
|
|||
|
|
try {
|
|||
|
|
const res = await api.put('/settings/email-design', designDraft);
|
|||
|
|
setEmailDesign(res.data);
|
|||
|
|
setDesignDraft(res.data);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setDesignSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetDesign = async () => {
|
|||
|
|
if (!window.confirm('Design auf Standardwerte zurücksetzen?')) return;
|
|||
|
|
try {
|
|||
|
|
const res = await api.post('/settings/email-design/reset');
|
|||
|
|
setEmailDesign(res.data);
|
|||
|
|
setDesignDraft(res.data);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const setDraft = (key, val) => setDesignDraft(d => ({ ...d, [key]: val }));
|
|||
|
|
|
|||
|
|
// ── Email template CRUD ───────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const saveEmailTpl = async () => {
|
|||
|
|
if (!emailModal?.subject?.trim() || !emailModal?.intro?.trim()) return;
|
|||
|
|
setEmailLoading(true);
|
|||
|
|
try {
|
|||
|
|
await api.put(`/settings/email-templates/${emailModal.type}`, {
|
|||
|
|
subject: emailModal.subject,
|
|||
|
|
intro: emailModal.intro,
|
|||
|
|
});
|
|||
|
|
await loadEmailTpls();
|
|||
|
|
setEmailModal(null);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setEmailLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetEmailTpl = async (type) => {
|
|||
|
|
if (!window.confirm('Vorlage auf Standardwerte zurücksetzen?')) return;
|
|||
|
|
try {
|
|||
|
|
await api.post(`/settings/email-templates/${type}/reset`);
|
|||
|
|
await loadEmailTpls();
|
|||
|
|
if (emailModal?.type === type) {
|
|||
|
|
const res = await api.get('/settings/email-templates');
|
|||
|
|
const updated = res.data.find(t => t.type === type);
|
|||
|
|
if (updated) setEmailModal(m => ({ ...m, subject: updated.subject, intro: updated.intro }));
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.message || 'Fehler beim Zurücksetzen');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const insertVar = (varName) => {
|
|||
|
|
const isSubject = lastFocused === 'subject';
|
|||
|
|
const el = isSubject ? emailSubjectRef.current : emailIntroRef.current;
|
|||
|
|
const field = isSubject ? 'subject' : 'intro';
|
|||
|
|
const token = `{{${varName}}}`;
|
|||
|
|
if (el) {
|
|||
|
|
const start = el.selectionStart ?? el.value.length;
|
|||
|
|
const end = el.selectionEnd ?? el.value.length;
|
|||
|
|
const newVal = el.value.substring(0, start) + token + el.value.substring(end);
|
|||
|
|
setEmailModal(m => ({ ...m, [field]: newVal }));
|
|||
|
|
setTimeout(() => {
|
|||
|
|
el.focus();
|
|||
|
|
el.selectionStart = el.selectionEnd = start + token.length;
|
|||
|
|
}, 0);
|
|||
|
|
} else {
|
|||
|
|
setEmailModal(m => ({ ...m, [field]: (m[field] || '') + token }));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const card = {
|
|||
|
|
background: 'var(--bg-card)',
|
|||
|
|
border: '1px solid var(--border-color)',
|
|||
|
|
borderRadius: 'var(--radius-xl)',
|
|||
|
|
padding: '24px',
|
|||
|
|
marginBottom: '16px',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const rowStyle = {
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: '12px',
|
|||
|
|
padding: '10px 14px',
|
|||
|
|
borderRadius: 'var(--radius-md)',
|
|||
|
|
background: 'var(--bg-secondary)',
|
|||
|
|
marginBottom: '8px',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const btnPrimary = {
|
|||
|
|
background: 'var(--cereda-primary)',
|
|||
|
|
color: '#fff',
|
|||
|
|
border: 'none',
|
|||
|
|
borderRadius: 'var(--radius-md)',
|
|||
|
|
padding: '8px 16px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontSize: '14px',
|
|||
|
|
fontWeight: 600,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const btnOutline = {
|
|||
|
|
background: 'transparent',
|
|||
|
|
color: 'var(--text-secondary)',
|
|||
|
|
border: '1px solid var(--border-color)',
|
|||
|
|
borderRadius: 'var(--radius-md)',
|
|||
|
|
padding: '6px 12px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontSize: '13px',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const btnDanger = {
|
|||
|
|
background: 'transparent',
|
|||
|
|
color: 'var(--danger)',
|
|||
|
|
border: '1px solid var(--danger)',
|
|||
|
|
borderRadius: 'var(--radius-md)',
|
|||
|
|
padding: '6px 12px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontSize: '13px',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="main-content" style={{ padding: '40px 24px' }}>
|
|||
|
|
|
|||
|
|
{/* ── Constrained header + tabs ─────────────────────────────────── */}
|
|||
|
|
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
|
|||
|
|
|
|||
|
|
{/* Header */}
|
|||
|
|
<div style={{ marginBottom: '28px' }}>
|
|||
|
|
<h1 style={{ fontSize: '22px', fontWeight: 700, color: 'var(--text-primary)', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|||
|
|
⚙️ Einstellungen
|
|||
|
|
</h1>
|
|||
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '14px', marginTop: '6px' }}>
|
|||
|
|
Ticket-Kategorien, Vorlagen, E-Mail-Texte und E-Mail-Design verwalten
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Tabs */}
|
|||
|
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--border-color)', paddingBottom: '0' }}>
|
|||
|
|
{[
|
|||
|
|
{ key: 'categories', label: '📂 Kategorien' },
|
|||
|
|
{ key: 'templates', label: '📝 Vorlagen' },
|
|||
|
|
{ key: 'asset-types', label: '🖥️ Asset-Typen' },
|
|||
|
|
{ key: 'email-templates', label: '📧 E-Mail-Vorlagen' },
|
|||
|
|
{ key: 'email-design', label: '🎨 E-Mail-Design' },
|
|||
|
|
].map(t => (
|
|||
|
|
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
|||
|
|
background: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
borderBottom: tab === t.key ? '2px solid var(--cereda-primary)' : '2px solid transparent',
|
|||
|
|
color: tab === t.key ? 'var(--cereda-primary)' : 'var(--text-secondary)',
|
|||
|
|
padding: '10px 18px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontWeight: tab === t.key ? 700 : 400,
|
|||
|
|
fontSize: '14px',
|
|||
|
|
marginBottom: '-1px',
|
|||
|
|
transition: 'var(--transition)',
|
|||
|
|
}}>
|
|||
|
|
{t.label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>{/* end constrained header/tabs */}
|
|||
|
|
|
|||
|
|
{/* ── Constrained tab content (all except email-design) ─────────── */}
|
|||
|
|
<div style={{ maxWidth: '860px', margin: '0 auto', display: tab === 'email-design' ? 'none' : 'block' }}>
|
|||
|
|
|
|||
|
|
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */}
|
|||
|
|
{tab === 'categories' && (
|
|||
|
|
<div>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|||
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
|
|||
|
|
{categories.length} Kategorien · werden beim Ticket-Erstellen angezeigt
|
|||
|
|
</p>
|
|||
|
|
<button style={btnPrimary} onClick={() => setCatModal({ name: '', icon: '📁' })}>
|
|||
|
|
+ Kategorie hinzufügen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={card}>
|
|||
|
|
{categories.length === 0 && (
|
|||
|
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Keine Kategorien vorhanden</p>
|
|||
|
|
)}
|
|||
|
|
{categories.map(cat => (
|
|||
|
|
<div key={cat.id} style={rowStyle}>
|
|||
|
|
<span style={{ fontSize: '20px', minWidth: '28px', textAlign: 'center' }}>{cat.icon}</span>
|
|||
|
|
<span style={{ flex: 1, fontWeight: 600, color: 'var(--text-primary)' }}>{cat.name}</span>
|
|||
|
|
<button style={btnOutline} onClick={() => setCatModal({ id: cat.id, name: cat.name, icon: cat.icon })}>
|
|||
|
|
Bearbeiten
|
|||
|
|
</button>
|
|||
|
|
<button style={btnDanger} onClick={() => deleteCategory(cat)}>
|
|||
|
|
Löschen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ── TEMPLATES TAB ─────────────────────────────────────────────── */}
|
|||
|
|
{tab === 'templates' && (
|
|||
|
|
<div>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|||
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
|
|||
|
|
{templates.length} Vorlagen · erscheinen im Ticket-Erstellungs-Dropdown
|
|||
|
|
</p>
|
|||
|
|
<button style={btnPrimary} onClick={() => setTplModal({ label: '', title: '', description: '', category: categories[0]?.name || 'Allgemein', priority: 'mittel' })}>
|
|||
|
|
+ Vorlage hinzufügen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={card}>
|
|||
|
|
{templates.length === 0 && (
|
|||
|
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Keine Vorlagen vorhanden</p>
|
|||
|
|
)}
|
|||
|
|
{templates.map(tpl => (
|
|||
|
|
<div key={tpl.id} style={rowStyle}>
|
|||
|
|
<span style={{ fontSize: '16px' }}>{PRIORITY_ICONS[tpl.priority]}</span>
|
|||
|
|
<div style={{ flex: 1 }}>
|
|||
|
|
<div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{tpl.label}</div>
|
|||
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>
|
|||
|
|
{tpl.category} · {tpl.title || <em>Kein Titel</em>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button style={btnOutline} onClick={() => setTplModal({ ...tpl })}>
|
|||
|
|
Bearbeiten
|
|||
|
|
</button>
|
|||
|
|
<button style={btnDanger} onClick={() => deleteTemplate(tpl)}>
|
|||
|
|
Löschen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ── ASSET TYPES TAB ──────────────────────────────────────────── */}
|
|||
|
|
{tab === 'asset-types' && <AssetTypesTab />}
|
|||
|
|
|
|||
|
|
{/* ── EMAIL TEMPLATES TAB ───────────────────────────────────────── */}
|
|||
|
|
{tab === 'email-templates' && (
|
|||
|
|
<div>
|
|||
|
|
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: '13px' }}>
|
|||
|
|
Betreff und Einleitungstext der automatischen E-Mails anpassen. Variablen wie <code>{'{{ticket_number}}'}</code> werden beim Versand ersetzt.
|
|||
|
|
</p>
|
|||
|
|
<div style={card}>
|
|||
|
|
{emailTpls.map(tpl => {
|
|||
|
|
const meta = EMAIL_TEMPLATE_LABELS[tpl.type] || { label: tpl.type, desc: '' };
|
|||
|
|
return (
|
|||
|
|
<div key={tpl.type} style={rowStyle}>
|
|||
|
|
<div style={{ flex: 1 }}>
|
|||
|
|
<div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{meta.label}</div>
|
|||
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>{meta.desc}</div>
|
|||
|
|
</div>
|
|||
|
|
<button style={btnOutline} onClick={() => setEmailModal({ ...tpl })}>Bearbeiten</button>
|
|||
|
|
<button style={btnDanger} onClick={() => resetEmailTpl(tpl.type)}>Zurücksetzen</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
</div>{/* end constrained tab content */}
|
|||
|
|
|
|||
|
|
{/* ── EMAIL DESIGN TAB – full width ─────────────────────────────── */}
|
|||
|
|
{tab === 'email-design' && designDraft && (
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '28px', alignItems: 'start' }}>
|
|||
|
|
|
|||
|
|
{/* Left: settings (sticky) */}
|
|||
|
|
<div style={{ position: 'sticky', top: '16px', maxHeight: 'calc(100vh - 80px)', overflowY: 'auto' }}>
|
|||
|
|
<div style={card}>
|
|||
|
|
<h3 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>🏷️ Marke</h3>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Logo-URL</label>
|
|||
|
|
<input className="form-input" value={designDraft.logo_url} onChange={e => setDraft('logo_url', e.target.value)} placeholder="https://... (leer = Emoji-Icon)" />
|
|||
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>Bild-URL (PNG/SVG/JPG). Wenn leer wird das Emoji-Icon verwendet.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Emoji-Icon (Fallback)</label>
|
|||
|
|
<input className="form-input" value={designDraft.brand_icon} onChange={e => setDraft('brand_icon', e.target.value)} placeholder="💻" style={{ fontSize: '18px', width: '80px' }} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Markenname</label>
|
|||
|
|
<input className="form-input" value={designDraft.brand_name} onChange={e => setDraft('brand_name', e.target.value)} placeholder="CEREDA SYSTEMS" />
|
|||
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>Erstes Wort dunkel, Rest in Primärfarbe.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Untertitel</label>
|
|||
|
|
<input className="form-input" value={designDraft.brand_subtitle} onChange={e => setDraft('brand_subtitle', e.target.value)} placeholder="IT Support" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<h3 style={{ margin: '20px 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>🎨 Farben</h3>
|
|||
|
|
|
|||
|
|
{[
|
|||
|
|
{ key: 'primary_color', label: 'Primärfarbe', desc: 'Akzentleiste, Links, Badge' },
|
|||
|
|
{ key: 'button_color', label: 'Button-Farbe', desc: 'Hintergrund des Haupt-Buttons' },
|
|||
|
|
{ key: 'bg_color', label: 'Hintergrundfarbe', desc: 'Äußerer E-Mail-Hintergrund' },
|
|||
|
|
].map(({ key, label, desc }) => (
|
|||
|
|
<div key={key} className="form-group">
|
|||
|
|
<label className="form-label">{label}</label>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|||
|
|
<input
|
|||
|
|
type="color"
|
|||
|
|
value={designDraft[key]}
|
|||
|
|
onChange={e => setDraft(key, e.target.value)}
|
|||
|
|
style={{ width: '44px', height: '36px', border: '1px solid var(--border-color)', borderRadius: '8px', cursor: 'pointer', padding: '2px' }}
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={designDraft[key]}
|
|||
|
|
onChange={e => setDraft(key, e.target.value)}
|
|||
|
|
placeholder="#0d9488"
|
|||
|
|
style={{ flex: 1, fontFamily: 'monospace', fontSize: '13px' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>{desc}</p>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
<h3 style={{ margin: '20px 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>📝 Footer</h3>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Firmenname</label>
|
|||
|
|
<input className="form-input" value={designDraft.company_name} onChange={e => setDraft('company_name', e.target.value)} placeholder="Cereda Systems GmbH" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Footer-Text</label>
|
|||
|
|
<textarea className="form-input" value={designDraft.footer_text} onChange={e => setDraft('footer_text', e.target.value)} rows={2} style={{ resize: 'vertical', fontSize: '13px' }} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: '10px', justifyContent: 'space-between', marginTop: '8px' }}>
|
|||
|
|
<button style={btnDanger} onClick={resetDesign}>Zurücksetzen</button>
|
|||
|
|
<button style={btnPrimary} onClick={saveDesign} disabled={designSaving}>
|
|||
|
|
{designSaving ? 'Speichern...' : 'Design speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Right: live preview */}
|
|||
|
|
<div>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
|||
|
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>Vorschau:</span>
|
|||
|
|
<select
|
|||
|
|
className="form-select"
|
|||
|
|
value={previewType}
|
|||
|
|
onChange={e => setPreviewType(e.target.value)}
|
|||
|
|
style={{ flex: 1, fontSize: '13px' }}
|
|||
|
|
>
|
|||
|
|
{Object.entries(EMAIL_TEMPLATE_LABELS).map(([k, v]) => (
|
|||
|
|
<option key={k} value={k}>{v.label}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
{/* Dark/Light mode toggle */}
|
|||
|
|
<button
|
|||
|
|
onClick={() => setPreviewDark(v => !v)}
|
|||
|
|
title={previewDark ? 'Zu Hell-Modus wechseln' : 'Zu Dunkel-Modus wechseln'}
|
|||
|
|
style={{
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: '6px',
|
|||
|
|
padding: '6px 12px',
|
|||
|
|
borderRadius: 'var(--radius-md)',
|
|||
|
|
border: '1px solid var(--border-color)',
|
|||
|
|
background: previewDark ? '#1e293b' : 'var(--bg-secondary)',
|
|||
|
|
color: previewDark ? '#e2e8f0' : 'var(--text-secondary)',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontSize: '13px',
|
|||
|
|
fontWeight: 500,
|
|||
|
|
whiteSpace: 'nowrap',
|
|||
|
|
transition: 'var(--transition)',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{previewDark ? '🌙 Dark' : '☀️ Light'}
|
|||
|
|
</button>
|
|||
|
|
{previewLoading && <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Lädt...</span>}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ border: '1px solid var(--border-color)', borderRadius: 'var(--radius-xl)', overflow: 'hidden', background: previewDark ? '#0f172a' : '#f1f5f9' }}>
|
|||
|
|
<iframe
|
|||
|
|
srcDoc={previewHtml}
|
|||
|
|
style={{ width: '100%', height: 'calc(100vh - 260px)', minHeight: '600px', border: 'none', display: 'block' }}
|
|||
|
|
title="E-Mail Vorschau"
|
|||
|
|
sandbox="allow-same-origin"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ── CATEGORY MODAL ────────────────────────────────────────────── */}
|
|||
|
|
{catModal && (
|
|||
|
|
<div className="modal-overlay" onClick={() => setCatModal(null)}>
|
|||
|
|
<div className="modal-content" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="modal-header">
|
|||
|
|
<h2 className="modal-title">{catModal.id ? '✏️ Kategorie bearbeiten' : '➕ Neue Kategorie'}</h2>
|
|||
|
|
<button className="modal-close" onClick={() => setCatModal(null)}>×</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Name*</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={catModal.name}
|
|||
|
|
onChange={e => setCatModal(p => ({ ...p, name: e.target.value }))}
|
|||
|
|
placeholder="z.B. Microsoft 365"
|
|||
|
|
autoFocus
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Icon (Emoji)</label>
|
|||
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '10px' }}>
|
|||
|
|
{COMMON_ICONS.map(ic => (
|
|||
|
|
<button
|
|||
|
|
key={ic}
|
|||
|
|
onClick={() => setCatModal(p => ({ ...p, icon: ic }))}
|
|||
|
|
style={{
|
|||
|
|
fontSize: '22px',
|
|||
|
|
background: catModal.icon === ic ? 'var(--cereda-primary)20' : 'var(--bg-secondary)',
|
|||
|
|
border: catModal.icon === ic ? '2px solid var(--cereda-primary)' : '2px solid transparent',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
width: '40px',
|
|||
|
|
height: '40px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
justifyContent: 'center',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{ic}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={catModal.icon}
|
|||
|
|
onChange={e => setCatModal(p => ({ ...p, icon: e.target.value }))}
|
|||
|
|
placeholder="Oder eigenes Emoji eingeben"
|
|||
|
|
style={{ fontSize: '18px' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
|||
|
|
<button style={btnOutline} onClick={() => setCatModal(null)}>Abbrechen</button>
|
|||
|
|
<button style={btnPrimary} onClick={saveCategory} disabled={catLoading || !catModal.name?.trim()}>
|
|||
|
|
{catLoading ? 'Speichern...' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ── TEMPLATE MODAL ────────────────────────────────────────────── */}
|
|||
|
|
{tplModal && (
|
|||
|
|
<div className="modal-overlay" onClick={() => setTplModal(null)}>
|
|||
|
|
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="modal-header">
|
|||
|
|
<h2 className="modal-title">{tplModal.id ? '✏️ Vorlage bearbeiten' : '➕ Neue Vorlage'}</h2>
|
|||
|
|
<button className="modal-close" onClick={() => setTplModal(null)}>×</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Anzeige-Name (Dropdown)*</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={tplModal.label}
|
|||
|
|
onChange={e => setTplModal(p => ({ ...p, label: e.target.value }))}
|
|||
|
|
placeholder="z.B. Passwort-Reset"
|
|||
|
|
autoFocus
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Ticket-Titel</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={tplModal.title}
|
|||
|
|
onChange={e => setTplModal(p => ({ ...p, title: e.target.value }))}
|
|||
|
|
placeholder="z.B. Passwort zurücksetzen"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Kategorie</label>
|
|||
|
|
<select
|
|||
|
|
className="form-select"
|
|||
|
|
value={tplModal.category}
|
|||
|
|
onChange={e => setTplModal(p => ({ ...p, category: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
{categories.map(c => (
|
|||
|
|
<option key={c.id} value={c.name}>{c.icon} {c.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Priorität</label>
|
|||
|
|
<select
|
|||
|
|
className="form-select"
|
|||
|
|
value={tplModal.priority}
|
|||
|
|
onChange={e => setTplModal(p => ({ ...p, priority: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
{PRIORITIES.map(p => (
|
|||
|
|
<option key={p} value={p}>{PRIORITY_ICONS[p]} {p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Beschreibungs-Vorlage</label>
|
|||
|
|
<textarea
|
|||
|
|
className="form-input"
|
|||
|
|
value={tplModal.description}
|
|||
|
|
onChange={e => setTplModal(p => ({ ...p, description: e.target.value }))}
|
|||
|
|
rows={6}
|
|||
|
|
placeholder="Vorlage-Text... (Leerzeilen als Platzhalter für den Nutzer)"
|
|||
|
|
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '13px' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '8px' }}>
|
|||
|
|
<button style={btnOutline} onClick={() => setTplModal(null)}>Abbrechen</button>
|
|||
|
|
<button style={btnPrimary} onClick={saveTemplate} disabled={tplLoading || !tplModal.label?.trim()}>
|
|||
|
|
{tplLoading ? 'Speichern...' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{/* ── EMAIL TEMPLATE MODAL ──────────────────────────────────────── */}
|
|||
|
|
{emailModal && (() => {
|
|||
|
|
const vars = EMAIL_TEMPLATE_VARS[emailModal.type] || [];
|
|||
|
|
const meta = EMAIL_TEMPLATE_LABELS[emailModal.type] || { label: emailModal.type };
|
|||
|
|
return (
|
|||
|
|
<div className="modal-overlay" onClick={() => setEmailModal(null)}>
|
|||
|
|
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="modal-header">
|
|||
|
|
<h2 className="modal-title">{meta.label} bearbeiten</h2>
|
|||
|
|
<button className="modal-close" onClick={() => setEmailModal(null)}>×</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Variable chips */}
|
|||
|
|
{vars.length > 0 && (
|
|||
|
|
<div style={{ marginBottom: '16px' }}>
|
|||
|
|
<label className="form-label" style={{ marginBottom: '6px', display: 'block' }}>
|
|||
|
|
Verfügbare Variablen <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(klicken zum Einfügen)</span>
|
|||
|
|
</label>
|
|||
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
|||
|
|
{vars.map(v => (
|
|||
|
|
<button
|
|||
|
|
key={v}
|
|||
|
|
onClick={() => insertVar(v)}
|
|||
|
|
style={{
|
|||
|
|
background: 'var(--cereda-primary)15',
|
|||
|
|
border: '1px solid var(--cereda-primary)40',
|
|||
|
|
borderRadius: '6px',
|
|||
|
|
padding: '3px 10px',
|
|||
|
|
fontSize: '12px',
|
|||
|
|
color: 'var(--cereda-primary)',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
fontFamily: 'monospace',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{`{{${v}}}`}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Betreff</label>
|
|||
|
|
<input
|
|||
|
|
ref={emailSubjectRef}
|
|||
|
|
className="form-input"
|
|||
|
|
value={emailModal.subject}
|
|||
|
|
onChange={e => setEmailModal(m => ({ ...m, subject: e.target.value }))}
|
|||
|
|
onFocus={() => setLastFocused('subject')}
|
|||
|
|
placeholder="E-Mail Betreff"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label className="form-label">Einleitungstext</label>
|
|||
|
|
<textarea
|
|||
|
|
ref={emailIntroRef}
|
|||
|
|
className="form-input"
|
|||
|
|
value={emailModal.intro}
|
|||
|
|
onChange={e => setEmailModal(m => ({ ...m, intro: e.target.value }))}
|
|||
|
|
onFocus={() => setLastFocused('intro')}
|
|||
|
|
rows={5}
|
|||
|
|
placeholder="Einleitungstext der E-Mail..."
|
|||
|
|
style={{ resize: 'vertical', fontSize: '13px', lineHeight: 1.6 }}
|
|||
|
|
/>
|
|||
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '12px', margin: '4px 0 0' }}>
|
|||
|
|
Zeilenumbrüche werden in der E-Mail übernommen. Kein HTML nötig.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'space-between', marginTop: '8px' }}>
|
|||
|
|
<button style={btnDanger} onClick={() => resetEmailTpl(emailModal.type)}>
|
|||
|
|
Auf Standard zurücksetzen
|
|||
|
|
</button>
|
|||
|
|
<div style={{ display: 'flex', gap: '12px' }}>
|
|||
|
|
<button style={btnOutline} onClick={() => setEmailModal(null)}>Abbrechen</button>
|
|||
|
|
<button
|
|||
|
|
style={btnPrimary}
|
|||
|
|
onClick={saveEmailTpl}
|
|||
|
|
disabled={emailLoading || !emailModal.subject?.trim() || !emailModal.intro?.trim()}
|
|||
|
|
>
|
|||
|
|
{emailLoading ? 'Speichern...' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Asset Types Tab ───────────────────────────────────────────────────────────
|
|||
|
|
function AssetTypesTab() {
|
|||
|
|
const [types, setTypes] = useState([]);
|
|||
|
|
const [modal, setModal] = useState(null); // null | { id?, name, icon }
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [deleting, setDeleting] = useState(null);
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
const data = await assetService.getTypes();
|
|||
|
|
setTypes(data);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => { load(); }, [load]);
|
|||
|
|
|
|||
|
|
const openNew = () => setModal({ name: '', icon: 'devices_other' });
|
|||
|
|
const openEdit = (t) => setModal({ ...t });
|
|||
|
|
|
|||
|
|
const save = async () => {
|
|||
|
|
if (!modal?.name?.trim()) return;
|
|||
|
|
setSaving(true);
|
|||
|
|
try {
|
|||
|
|
if (modal.id) {
|
|||
|
|
await assetService.updateType(modal.id, { name: modal.name.trim(), icon: modal.icon });
|
|||
|
|
} else {
|
|||
|
|
await assetService.createType({ name: modal.name.trim(), icon: modal.icon });
|
|||
|
|
}
|
|||
|
|
setModal(null);
|
|||
|
|
await load();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.response?.data?.error || 'Fehler beim Speichern');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const remove = async (id) => {
|
|||
|
|
if (!window.confirm('Typ wirklich löschen?')) return;
|
|||
|
|
setDeleting(id);
|
|||
|
|
try {
|
|||
|
|
await assetService.deleteType(id);
|
|||
|
|
await load();
|
|||
|
|
} finally {
|
|||
|
|
setDeleting(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const card = { background: 'var(--bg-card)', borderRadius: '12px', border: '1px solid var(--border-color)', overflow: 'hidden' };
|
|||
|
|
const row = { display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border-color)', gap: '12px' };
|
|||
|
|
const btnPrimary = { background: 'var(--cereda-primary)', color: '#fff', border: 'none', borderRadius: '8px', padding: '8px 16px', cursor: 'pointer', fontSize: '13px', fontWeight: 600 };
|
|||
|
|
const btnDanger = { background: 'rgba(239,68,68,0.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' };
|
|||
|
|
const btnOutline = { background: 'transparent', color: 'var(--cereda-primary)', border: '1px solid var(--cereda-primary)', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' };
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|||
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
|
|||
|
|
Asset-Typen verwalten — werden in allen Dropdowns angezeigt.
|
|||
|
|
</p>
|
|||
|
|
<button style={btnPrimary} onClick={openNew}>+ Neuer Typ</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={card}>
|
|||
|
|
{types.length === 0 && (
|
|||
|
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Typen vorhanden</div>
|
|||
|
|
)}
|
|||
|
|
{types.map((t, i) => (
|
|||
|
|
<div key={t.id} style={{ ...row, borderBottom: i < types.length - 1 ? '1px solid var(--border-color)' : 'none' }}>
|
|||
|
|
<span style={{ fontSize: '20px', width: '28px', textAlign: 'center' }}>🖥️</span>
|
|||
|
|
<span style={{ flex: 1, fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{t.name}</span>
|
|||
|
|
<button style={btnOutline} onClick={() => openEdit(t)}>Bearbeiten</button>
|
|||
|
|
<button style={btnDanger} disabled={deleting === t.id} onClick={() => remove(t.id)}>
|
|||
|
|
{deleting === t.id ? '...' : 'Löschen'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{modal && (
|
|||
|
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
|||
|
|
<div style={{ background: 'var(--bg-modal)', borderRadius: '16px', padding: '24px', width: '360px', border: '1px solid var(--border-color)' }}>
|
|||
|
|
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)' }}>{modal.id ? 'Typ bearbeiten' : 'Neuer Asset-Typ'}</h3>
|
|||
|
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: 'var(--text-secondary)', fontWeight: 600 }}>Name *</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={modal.name}
|
|||
|
|
onChange={e => setModal(m => ({ ...m, name: e.target.value }))}
|
|||
|
|
placeholder="z.B. Notebook"
|
|||
|
|
autoFocus
|
|||
|
|
style={{ marginBottom: '20px' }}
|
|||
|
|
/>
|
|||
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
|||
|
|
<button style={btnOutline} onClick={() => setModal(null)}>Abbrechen</button>
|
|||
|
|
<button style={btnPrimary} disabled={saving || !modal.name?.trim()} onClick={save}>
|
|||
|
|
{saving ? 'Speichern...' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|