@@ -1030,7 +1042,7 @@ const AssetsPage = () => {
{detail.status === 'zugewiesen' && (
window.open(`${API}/assets/${detail.id}/handover-protocol`, '_blank')}
+ onClick={() => openPdf(`${API}/assets/${detail.id}/handover-protocol`)}
>
📋
@@ -1099,25 +1111,13 @@ const AssetsPage = () => {
{assets.length}
- {/* Suchmodus Toggle */}
-
- { setSearchMode('asset'); setSearchTerm(''); }}
- >🔍 Asset suchen
- { setSearchMode('user'); setSearchTerm(''); }}
- >👤 Benutzer suchen
-
-
{/* Suchfeld */}
- {searchMode === 'user' ? '👤' : '🔍'}
+ 🔍
setSearchTerm(e.target.value)}
/>
@@ -1200,7 +1200,7 @@ const AssetsPage = () => {
)}
{filteredAssets.map(asset => {
- const agent = getAgent(asset.name);
+ const agent = getAgent(asset);
const online = isOnline(agent);
return (
(
setTab(t.key)} style={{
background: 'none',
@@ -332,7 +334,7 @@ export default function SettingsPage() {
{/* end constrained header/tabs */}
{/* ── Constrained tab content (all except email-design) ─────────── */}
-
+
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */}
{tab === 'categories' && (
@@ -430,6 +432,9 @@ export default function SettingsPage() {
)}
+ {/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
+ {tab === 'cronjobs' &&
}
+
{/* 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
Lade Cronjobs…
;
+
+ return (
+
+
+
+ {jobs.length} Hintergrund-Jobs · Klick auf „Logs" für Details
+
+
+ ↻ Aktualisieren
+
+
+
+ {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
+ )}
+
+
+ openLogs(job)}
+ >
+ Logs
+
+ {job.canRun && (
+ runJob(job)}
+ >
+ {isRunning ? '⏳ Läuft…' : '▶ Ausführen'}
+
+ )}
+
+
+
+ );
+ })}
+
+ {/* Log Modal */}
+ {logModal && (
+
setLogModal(null)}>
+
e.stopPropagation()}>
+
+
{logModal.icon} {logModal.label} — Logs
+ setLogModal(null)}>×
+
+
+ {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([]);
diff --git a/frontend/src/pages/SystemPage.jsx b/frontend/src/pages/SystemPage.jsx
index e443f0b..79a81c0 100644
--- a/frontend/src/pages/SystemPage.jsx
+++ b/frontend/src/pages/SystemPage.jsx
@@ -40,6 +40,13 @@ const SystemPage = () => {
path: '/docs',
color: 'var(--warning)',
},
+ {
+ icon: '⏰',
+ title: 'Cronjobs',
+ description: 'Geplante Hintergrundaufgaben verwalten — Status, Logs und manuelle Ausführung',
+ path: '/settings',
+ color: 'var(--cereda-primary)',
+ },
];
return (
diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx
index aa3588f..6f8f2d2 100644
--- a/frontend/src/pages/UserManagementPage.jsx
+++ b/frontend/src/pages/UserManagementPage.jsx
@@ -105,6 +105,16 @@ const BarIcon = () =>
;
const Avatar = ({ user, size = 36, radius = 10, fontSize = 13 }) => {
const initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '')).toUpperCase() || (user.username?.[0] || '?').toUpperCase();
const bg = ROLE_AV_COLORS[user.role_name] || '#475569';
+ if (user.avatar_url) {
+ return (
+
{ e.target.style.display = 'none'; e.target.nextSibling && (e.target.nextSibling.style.display = 'flex'); }}
+ />
+ );
+ }
return (
{initials}
@@ -1152,9 +1162,13 @@ const UserManagementPage = () => {
const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?';
return (
handleSelect(u.id)}>
-
- {init}
-
+ {u.avatar_url ? (
+
{ e.target.style.display='none'; }} />
+ ) : (
+
+ {init}
+
+ )}
{u.first_name} {u.last_name}
@@ -1222,8 +1236,12 @@ const UserManagementPage = () => {
{/* Header */}
-
- {initials}
+
+ {selectedUser.avatar_url ? (
+
{ e.target.style.display='none'; e.target.closest('.bv-dh-avatar').style.background = ROLE_AV_COLORS[selectedUser.role_name] || '#475569'; }} />
+ ) : (
+ initials
+ )}