Add: Entra Cronjob, Cronjob-Verwaltung, Profilfotos, PDF-Auth-Fix, Tickets-Demo

This commit is contained in:
2026-06-02 11:57:44 +02:00
parent d9d5e4f618
commit 73d672ba88
10 changed files with 1559 additions and 50 deletions

View File

@@ -1,6 +1,7 @@
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' },
@@ -311,6 +312,7 @@ export default function SettingsPage() {
{ 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 => (
<button key={t.key} onClick={() => setTab(t.key)} style={{
background: 'none',
@@ -332,7 +334,7 @@ export default function SettingsPage() {
</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' }}>
<div style={{ maxWidth: '860px', margin: '0 auto', display: (tab === 'email-design') ? 'none' : 'block' }}>
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */}
{tab === 'categories' && (
@@ -430,6 +432,9 @@ export default function SettingsPage() {
</div>
)}
{/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
{tab === 'cronjobs' && <CronJobsTab />}
</div>{/* end constrained tab content */}
{/* ── EMAIL DESIGN TAB full width ─────────────────────────────── */}
@@ -797,6 +802,226 @@ export default function SettingsPage() {
);
}
// ── 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 <div style={{ padding: '40px', textAlign: 'center', color: 'var(--text-muted)' }}>Lade Cronjobs</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
{jobs.length} Hintergrund-Jobs · Klick auf Logs" für Details
</p>
<button
style={{ background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '6px 14px', cursor: 'pointer', fontSize: '13px' }}
onClick={loadJobs}
>
↻ Aktualisieren
</button>
</div>
{jobs.map(job => {
const badge = statusBadge(job.lastRun);
const isRunning = running[job.name];
return (
<div key={job.name} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<span style={{ fontSize: '22px', flexShrink: 0 }}>{job.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: '14px', color: 'var(--text-primary)' }}>{job.label}</span>
<span style={{
fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999,
background: 'var(--bg-secondary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)',
}}>{job.schedule}</span>
<span style={{
fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999,
background: badge.bg, color: badge.color, border: `1px solid ${badge.border}`,
}}>{badge.label}</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '3px' }}>{job.description}</div>
{job.lastRun && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '4px', display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<span>Letzter Lauf: {fmtDateTime(job.lastRun.started_at)}</span>
<span>Dauer: {fmtDuration(job.lastRun.started_at, job.lastRun.finished_at)}</span>
{job.lastRun.message && <span style={{ color: job.lastRun.status === 'error' ? '#ef4444' : 'var(--text-muted)' }}>{job.lastRun.message}</span>}
</div>
)}
{!job.lastRun && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '4px' }}>Noch kein Lauf protokolliert</div>
)}
</div>
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
<button
style={{ background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' }}
onClick={() => openLogs(job)}
>
Logs
</button>
{job.canRun && (
<button
disabled={isRunning}
style={{ background: isRunning ? 'var(--bg-secondary)' : 'var(--cereda-primary)', color: isRunning ? 'var(--text-muted)' : '#fff', border: 'none', borderRadius: 'var(--radius-md)', padding: '6px 14px', cursor: isRunning ? 'not-allowed' : 'pointer', fontSize: '12px', fontWeight: 600 }}
onClick={() => runJob(job)}
>
{isRunning ? '⏳ Läuft…' : '▶ Ausführen'}
</button>
)}
</div>
</div>
</div>
);
})}
{/* Log Modal */}
{logModal && (
<div className="modal-overlay" onClick={() => setLogModal(null)}>
<div className="modal-content modal-large" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{logModal.icon} {logModal.label} — Logs</h2>
<button className="modal-close" onClick={() => setLogModal(null)}>×</button>
</div>
{logsLoading ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)' }}>Lade Logs…</div>
) : logs.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Logs vorhanden</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
{['Datum', 'Status', 'Dauer', 'Meldung'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600, fontSize: '12px' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{logs.map(log => {
const s = logStatusBadge(log.status);
return (
<tr key={log.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '8px 12px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{fmtDateTime(log.started_at)}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999, background: s.bg, color: s.color }}>{s.label}</span>
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{fmtDuration(log.started_at, log.finished_at)}</td>
<td style={{ padding: '8px 12px', color: log.status === 'error' ? '#ef4444' : 'var(--text-secondary)', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.message || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
);
}
// ── Asset Types Tab ───────────────────────────────────────────────────────────
function AssetTypesTab() {
const [types, setTypes] = useState([]);