import React, { useState, useEffect, useCallback, useRef } from 'react';
import api from '../services/api';
import assetService from '../services/assetService';
import { toast } from 'react-toastify';
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 (
{/* ββ Constrained header + tabs βββββββββββββββββββββββββββββββββββ */}
{/* Header */}
βοΈ Einstellungen
Ticket-Kategorien, Vorlagen, E-Mail-Texte und E-Mail-Design verwalten
{/* Tabs */}
{[
{ 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' },
{ key: 'cronjobs', label: 'β° Cronjobs' },
].map(t => (
))}
{/* end constrained header/tabs */}
{/* ββ Constrained tab content (all except email-design) βββββββββββ */}
{/* ββ CATEGORIES TAB ββββββββββββββββββββββββββββββββββββββββββββββ */}
{tab === 'categories' && (
{categories.length} Kategorien Β· werden beim Ticket-Erstellen angezeigt
{categories.length === 0 && (
Keine Kategorien vorhanden
)}
{categories.map(cat => (
{cat.icon}
{cat.name}
))}
)}
{/* ββ TEMPLATES TAB βββββββββββββββββββββββββββββββββββββββββββββββ */}
{tab === 'templates' && (
{templates.length} Vorlagen Β· erscheinen im Ticket-Erstellungs-Dropdown
{templates.length === 0 && (
Keine Vorlagen vorhanden
)}
{templates.map(tpl => (
{PRIORITY_ICONS[tpl.priority]}
{tpl.label}
{tpl.category} Β· {tpl.title || Kein Titel}
))}
)}
{/* ββ ASSET TYPES TAB ββββββββββββββββββββββββββββββββββββββββββββ */}
{tab === 'asset-types' &&
}
{/* ββ EMAIL TEMPLATES TAB βββββββββββββββββββββββββββββββββββββββββ */}
{tab === 'email-templates' && (
Betreff und Einleitungstext der automatischen E-Mails anpassen. Variablen wie {'{{ticket_number}}'} werden beim Versand ersetzt.
{emailTpls.map(tpl => {
const meta = EMAIL_TEMPLATE_LABELS[tpl.type] || { label: tpl.type, desc: '' };
return (
);
})}
)}
{/* ββ CRONJOBS TAB ββββββββββββββββββββββββββββββββββββββββββββββββ */}
{tab === 'cronjobs' &&
}
{/* end constrained tab content */}
{/* ββ EMAIL DESIGN TAB β full width βββββββββββββββββββββββββββββββ */}
{tab === 'email-design' && designDraft && (
{/* Left: settings (sticky) */}
π·οΈ Marke
setDraft('brand_icon', e.target.value)} placeholder="π»" style={{ fontSize: '18px', width: '80px' }} />
setDraft('brand_subtitle', e.target.value)} placeholder="IT Support" />
π¨ Farben
{[
{ 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 }) => (
))}
π Footer
setDraft('company_name', e.target.value)} placeholder="Cereda Systems GmbH" />
{/* Right: live preview */}
Vorschau:
{/* Dark/Light mode toggle */}
{previewLoading && LΓ€dt...}
)}
{/* ββ CATEGORY MODAL ββββββββββββββββββββββββββββββββββββββββββββββ */}
{catModal && (
setCatModal(null)}>
e.stopPropagation()}>
{catModal.id ? 'βοΈ Kategorie bearbeiten' : 'β Neue Kategorie'}
setCatModal(p => ({ ...p, name: e.target.value }))}
placeholder="z.B. Microsoft 365"
autoFocus
/>
{COMMON_ICONS.map(ic => (
))}
setCatModal(p => ({ ...p, icon: e.target.value }))}
placeholder="Oder eigenes Emoji eingeben"
style={{ fontSize: '18px' }}
/>
)}
{/* ββ TEMPLATE MODAL ββββββββββββββββββββββββββββββββββββββββββββββ */}
{tplModal && (
setTplModal(null)}>
e.stopPropagation()}>
{tplModal.id ? 'βοΈ Vorlage bearbeiten' : 'β Neue Vorlage'}
)}
{/* ββ EMAIL TEMPLATE MODAL ββββββββββββββββββββββββββββββββββββββββ */}
{emailModal && (() => {
const vars = EMAIL_TEMPLATE_VARS[emailModal.type] || [];
const meta = EMAIL_TEMPLATE_LABELS[emailModal.type] || { label: emailModal.type };
return (
setEmailModal(null)}>
e.stopPropagation()}>
{meta.label} bearbeiten
{/* Variable chips */}
{vars.length > 0 && (
{vars.map(v => (
))}
)}
setEmailModal(m => ({ ...m, subject: e.target.value }))}
onFocus={() => setLastFocused('subject')}
placeholder="E-Mail Betreff"
/>
);
})()}
);
}
// ββ Cronjobs Tab βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const authFetchCron = (url, opts = {}) => {
const token = localStorage.getItem('token');
const apiBase = process.env.REACT_APP_API_URL || '/api';
return fetch(`${apiBase}${url}`, {
...opts,
headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
};
const fmtDuration = (started, finished) => {
if (!started || !finished) return 'β';
const ms = new Date(finished) - new Date(started);
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
};
const fmtDateTime = (d) => {
if (!d) return 'β';
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
function CronJobsTab() {
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(true);
const [logModal, setLogModal] = useState(null); // { job } | null
const [logs, setLogs] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
const [running, setRunning] = useState({}); // { [jobName]: true }
const loadJobs = useCallback(async () => {
try {
const r = await authFetchCron('/cron');
const d = await r.json();
setJobs(d.data || []);
} catch (e) {
toast.error('Fehler beim Laden der Cronjobs');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadJobs(); }, [loadJobs]);
const openLogs = async (job) => {
setLogModal(job);
setLogsLoading(true);
setLogs([]);
try {
const r = await authFetchCron(`/cron/${job.name}/logs`);
const d = await r.json();
setLogs(d.data || []);
} catch {
setLogs([]);
} finally {
setLogsLoading(false);
}
};
const runJob = async (job) => {
setRunning(p => ({ ...p, [job.name]: true }));
try {
const r = await authFetchCron(`/cron/${job.name}/run`, { method: 'POST' });
const d = await r.json();
if (r.ok) {
toast.success(`${job.label} gestartet`);
setTimeout(() => loadJobs(), 3000); // Nach 3s neu laden
} else {
toast.error(d.message || 'Fehler beim Starten');
}
} catch {
toast.error('Fehler beim Starten des Jobs');
} finally {
setTimeout(() => setRunning(p => ({ ...p, [job.name]: false })), 2000);
}
};
const statusBadge = (lastRun) => {
if (!lastRun) return { label: 'Noch nie', color: '#64748b', bg: 'rgba(100,116,139,0.1)', border: 'rgba(100,116,139,0.2)' };
if (lastRun.status === 'success') return { label: 'Erfolgreich', color: '#10b981', bg: 'rgba(16,185,129,0.1)', border: 'rgba(16,185,129,0.2)' };
if (lastRun.status === 'error') return { label: 'Fehler', color: '#ef4444', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.2)' };
if (lastRun.status === 'running') return { label: 'LΓ€uft', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.2)' };
return { label: lastRun.status, color: '#64748b', bg: 'rgba(100,116,139,0.1)', border: 'rgba(100,116,139,0.2)' };
};
const logStatusBadge = (status) => {
if (status === 'success') return { label: 'Erfolgreich', color: '#10b981', bg: 'rgba(16,185,129,0.1)' };
if (status === 'error') return { label: 'Fehler', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' };
if (status === 'running') return { label: 'LΓ€uft', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' };
return { label: status, color: '#64748b', bg: 'rgba(100,116,139,0.1)' };
};
const card = {
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-xl)',
padding: '18px 20px',
marginBottom: '12px',
};
if (loading) return Lade Cronjobsβ¦
;
return (
{jobs.length} Hintergrund-Jobs Β· Klick auf βLogs" fΓΌr Details
{jobs.map(job => {
const badge = statusBadge(job.lastRun);
const isRunning = running[job.name];
return (
{job.icon}
{job.label}
{job.schedule}
{badge.label}
{job.description}
{job.lastRun && (
Letzter Lauf: {fmtDateTime(job.lastRun.started_at)}
Dauer: {fmtDuration(job.lastRun.started_at, job.lastRun.finished_at)}
{job.lastRun.message && {job.lastRun.message}}
)}
{!job.lastRun && (
Noch kein Lauf protokolliert
)}
{job.canRun && (
)}
);
})}
{/* Log Modal */}
{logModal && (
setLogModal(null)}>
e.stopPropagation()}>
{logModal.icon} {logModal.label} β Logs
{logsLoading ? (
Lade Logsβ¦
) : logs.length === 0 ? (
Keine Logs vorhanden
) : (
{['Datum', 'Status', 'Dauer', 'Meldung'].map(h => (
| {h} |
))}
{logs.map(log => {
const s = logStatusBadge(log.status);
return (
| {fmtDateTime(log.started_at)} |
{s.label}
|
{fmtDuration(log.started_at, log.finished_at)} |
{log.message || 'β'}
|
);
})}
)}
)}
);
}
// ββ 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 (
Asset-Typen verwalten β werden in allen Dropdowns angezeigt.
{types.length === 0 && (
Keine Typen vorhanden
)}
{types.map((t, i) => (
π₯οΈ
{t.name}
))}
{modal && (
)}
);
}