Security: - H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl) - H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert - H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3) - registerLimiter exportiert (Crash-Bug: Route.post ohne Callback) - LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects) - LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512) - Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv - DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt UX: - Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten - Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende - Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
300 lines
14 KiB
JavaScript
300 lines
14 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useToast } from '../components/Toast';
|
|
import FillModal from '../components/FillModal';
|
|
import TemplateCard from '../components/TemplateCard';
|
|
import { apiFetch } from '../utils/api';
|
|
|
|
export default function Dashboard({ onNavigate }) {
|
|
const { user } = useAuth();
|
|
const toast = useToast();
|
|
const isAdmin = user?.role === 'admin';
|
|
const [templates, setTemplates] = useState([]);
|
|
const [stats, setStats] = useState(null);
|
|
const [search, setSearch] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [fillTemplate, setFillTemplate] = useState(null);
|
|
// Pagination für die Vorlagen-Table-Liste (10 Einträge pro Seite)
|
|
const TEMPLATES_PER_PAGE = 10;
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/templates');
|
|
const data = await res.json();
|
|
setTemplates(data);
|
|
} catch (e) {
|
|
console.error('Fehler beim Laden der Vorlagen:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchStats = useCallback(async () => {
|
|
if (!isAdmin) return;
|
|
try {
|
|
const res = await apiFetch('/stats');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setStats(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Fehler beim Laden der Statistiken:', e);
|
|
}
|
|
}, [isAdmin]);
|
|
|
|
useEffect(() => {
|
|
fetchTemplates();
|
|
fetchStats();
|
|
const interval = setInterval(fetchTemplates, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchTemplates, fetchStats]);
|
|
|
|
// Alle Vorlagen sind für jeden User sichtbar (nicht mehr nach is_assignable gefiltert)
|
|
const topTemplates = stats?.topTemplates || [];
|
|
const filtered = templates.filter((t) =>
|
|
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(t.description || '').toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
// Pagination: Suchwechsel setzt auf Seite 1 zurück
|
|
const totalPages = Math.max(Math.ceil(filtered.length / TEMPLATES_PER_PAGE), 1);
|
|
const pagedTemplates = filtered.slice((currentPage - 1) * TEMPLATES_PER_PAGE, currentPage * TEMPLATES_PER_PAGE);
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [search]);
|
|
|
|
const handleSubmitTask = async (payload) => {
|
|
const res = await apiFetch('/tasks', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ...payload, user_id: user.id }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || 'Fehler beim Absenden');
|
|
}
|
|
setFillTemplate(null);
|
|
toast.success('Aufgabe erfolgreich abgesendet!');
|
|
fetchStats();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-4 md:p-6 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="static-skeleton h-24 w-full"></div>
|
|
<div className="static-skeleton h-24 w-full"></div>
|
|
<div className="static-skeleton h-24 w-full"></div>
|
|
<div className="static-skeleton h-24 w-full"></div>
|
|
</div>
|
|
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn"></div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div className="static-skeleton h-32 w-full"></div>
|
|
<div className="static-skeleton h-32 w-full"></div>
|
|
<div className="static-skeleton h-32 w-full"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 md:p-6">
|
|
{/* KPI-Sektion: 4 Statistik-Karten mit Delta + Fortschrittsbalken */}
|
|
{isAdmin && stats && (
|
|
<section aria-label="Kennzahlen" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
|
{/* StatCard: Offene Aufgaben */}
|
|
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
|
<div className="card-body p-5 space-y-3">
|
|
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Offene Aufgaben</span>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold tabular-nums">{stats.openTasks}</span>
|
|
{stats.totalTasks > 0 && (
|
|
<span className="text-xs font-medium text-primary">
|
|
{Math.round((stats.openTasks / stats.totalTasks) * 100)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
|
<div className="h-full bg-primary transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.openTasks / stats.totalTasks) * 100 : 0}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* StatCard: Abgeschlossen */}
|
|
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
|
<div className="card-body p-5 space-y-3">
|
|
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Abgeschlossen</span>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold tabular-nums">{stats.completedTasks}</span>
|
|
{stats.totalTasks > 0 && (
|
|
<span className="text-xs font-medium text-success">
|
|
{Math.round((stats.completedTasks / stats.totalTasks) * 100)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
|
<div className="h-full bg-success transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.completedTasks / stats.totalTasks) * 100 : 0}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* StatCard: Aktive Nutzer */}
|
|
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
|
<div className="card-body p-5 space-y-3">
|
|
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Aktive Nutzer</span>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold tabular-nums">{stats.activeUsers}</span>
|
|
{stats.totalUsers > 0 && (
|
|
<span className="text-xs font-medium text-secondary">
|
|
von {stats.totalUsers}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
|
<div className="h-full bg-secondary transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.activeUsers / stats.totalUsers) * 100 : 0}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* StatCard: AD-Nutzer */}
|
|
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
|
<div className="card-body p-5 space-y-3">
|
|
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">AD-Nutzer</span>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold tabular-nums">{stats.adUsers}</span>
|
|
</div>
|
|
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
|
<div className="h-full bg-warning transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.adUsers / stats.totalUsers) * 100 : 0}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Haupt-Grid: Vorlagen-Liste (links) | Graph-Card (rechts) — gleich große Spalten */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-stretch">
|
|
{/* Linke Spalte: Vorlagen als Table-Liste */}
|
|
<section aria-label="Vorlagen" className="flex flex-col">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
|
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
|
|
<div className="form-control w-full sm:w-72">
|
|
<input
|
|
type="text"
|
|
placeholder="Vorlagen durchsuchen..."
|
|
className="input input-bordered w-full"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{templates.length === 0 ? (
|
|
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
|
<p className="text-sm opacity-60">Keine Vorlagen vorhanden.</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
|
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Scrollbare Liste — flex-1 + min-h-0 (Pflicht für overflow in Flexbox),
|
|
damit die Liste innerhalb der Card-Höhe scrollt statt sie aufzuspannen */}
|
|
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 flex flex-col min-h-[600px] max-h-[600px]">
|
|
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
|
|
{pagedTemplates.map((tpl) => (
|
|
<TemplateCard
|
|
key={tpl.id}
|
|
template={tpl}
|
|
action={
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => setFillTemplate(tpl)}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
Starten
|
|
</button>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pagination: 10 Vorlagen pro Seite */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center items-center gap-2 mt-4">
|
|
<button
|
|
className="btn btn-sm btn-outline"
|
|
disabled={currentPage <= 1}
|
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
|
>
|
|
« Zurück
|
|
</button>
|
|
<span className="text-sm opacity-70">
|
|
Seite {currentPage} von {totalPages}
|
|
</span>
|
|
<button
|
|
className="btn btn-sm btn-outline"
|
|
disabled={currentPage >= totalPages}
|
|
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
|
>
|
|
Weiter »
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* Rechte Spalte: Meistgenutzte Vorlagen als Column-Chart (Anteil in %) */}
|
|
{isAdmin && topTemplates.length > 0 && (
|
|
<section aria-label="Meistgenutzte Vorlagen" className="flex flex-col">
|
|
{/* Header mit gleicher Mindesthöhe wie der Listen-Header (Suchbox = 48px),
|
|
damit beide Cards auf derselben Höhe starten */}
|
|
<div className="flex items-center justify-between mb-4 sm:min-h-[48px]">
|
|
<h2 className="text-xl font-medium tracking-tight">Meistgenutzt</h2>
|
|
</div>
|
|
<div className="card bg-base-200 shadow-sm rounded-xl flex-1 flex flex-col">
|
|
<div className="card-body p-5 flex-1 flex flex-col justify-center">
|
|
{(() => {
|
|
// Prozentanteil jeder Vorlage an allen Task-Zuordnungen der Top-Liste
|
|
const totalCount = topTemplates.reduce((sum, t) => sum + (t.task_count || 0), 0);
|
|
if (totalCount === 0) return null;
|
|
return (
|
|
<div className="flex items-end justify-around gap-2 sm:gap-4 h-64 pt-2">
|
|
{topTemplates.map((t, idx) => {
|
|
const pct = Math.round(((t.task_count || 0) / totalCount) * 100);
|
|
return (
|
|
<div key={t.id} className="flex flex-col items-center justify-end h-full flex-1 min-w-0">
|
|
{/* Prozentwert über der Säule */}
|
|
<span className="text-xs font-semibold tabular-nums mb-1">{pct}%</span>
|
|
{/* Säule: Höhe proportional zum Prozentanteil, animiert per CSS-Transition */}
|
|
<div
|
|
className={`w-full max-w-16 rounded-t-md transition-all duration-700 ${idx === 0 ? 'bg-primary' : 'bg-primary/50'}`}
|
|
style={{ height: `${Math.max(pct, 2)}%` }}
|
|
/>
|
|
{/* Beschriftung unter der Säule */}
|
|
<span className="text-[11px] opacity-70 truncate w-full text-center mt-2" title={t.name}>
|
|
{t.name}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
{fillTemplate && (
|
|
<FillModal
|
|
template={fillTemplate}
|
|
onSubmit={handleSubmitTask}
|
|
onClose={() => setFillTemplate(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |