Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,410 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import portalGuideService from '../services/portalGuideService';
import { toast } from 'react-toastify';
const CATEGORIES = ['Allgemein', 'IT', 'HR', 'Buchhaltung', 'Produktion', 'Verwaltung', 'Sonstiges'];
const ICONS = ['📄', '📘', '📗', '📙', '📕', '📋', '🖥️', '🔧', '📊', '🛡️', '🌐', '📱', '🔑', '⚙️', '🏢'];
const ROLE_GROUPS = [
{ label: 'IT-Team', roles: ['super_admin', 'admin', 'support', 'bearbeiter'], icon: '🖥️' },
{ label: 'HR Personal', roles: ['hr_personal'], icon: '🧑‍💼' },
{ label: 'Buchhaltung', roles: ['buchhaltung'], icon: '💶' },
{ label: 'Produktion', roles: ['produktion', 'techniker'], icon: '🏭' },
{ label: 'Benutzer', roles: ['benutzer'], icon: '👤' },
];
export default function PortalPage() {
const { isAdmin, isSuperAdmin } = useAuth();
const canAdmin = isAdmin() || isSuperAdmin();
const [guides, setGuides] = useState([]);
const [loading, setLoading] = useState(true);
const [categoryFilter, setCategoryFilter] = useState('all');
const [search, setSearch] = useState('');
// Modal state
const [modal, setModal] = useState(null); // null | { mode: 'create'|'edit', guide? }
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ title: '', category: 'Allgemein', description: '', icon: '📄', html_content: '', visible_roles: [] });
const fileInputRef = useRef(null);
// Viewer state
const [viewer, setViewer] = useState(null); // null | guide object
const [viewerHtml, setViewerHtml] = useState('');
const load = async () => {
try {
const data = await portalGuideService.getAll();
setGuides(data);
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const filtered = guides.filter(g => {
const matchCat = categoryFilter === 'all' || g.category === categoryFilter;
const q = search.toLowerCase();
const matchSearch = !q || g.title.toLowerCase().includes(q) || g.category.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
return matchCat && matchSearch;
});
const categories = ['all', ...new Set(guides.map(g => g.category))];
const openCreate = () => {
setForm({ title: '', category: 'Allgemein', description: '', icon: '📄', html_content: '', visible_roles: [] });
setModal({ mode: 'create' });
};
const openEdit = async (g) => {
const full = await portalGuideService.getById(g.id);
let vr = [];
try { vr = JSON.parse(full.visible_roles || '[]'); } catch {}
setForm({ title: full.title, category: full.category, description: full.description || '', icon: full.icon || '📄', html_content: full.html_content, visible_roles: vr });
setModal({ mode: 'edit', guide: g });
};
const openViewer = async (g) => {
setViewer(g);
setViewerHtml('');
try {
const full = await portalGuideService.getById(g.id);
setViewerHtml(full.html_content || '');
} catch {
setViewerHtml('<p style="color:red">Fehler beim Laden der Anleitung.</p>');
}
};
const handleFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setForm(f => ({ ...f, html_content: ev.target.result, title: f.title || file.name.replace(/\.html?$/i, '') }));
};
reader.readAsText(file, 'UTF-8');
};
const save = async () => {
if (!form.title.trim()) return toast.error('Titel erforderlich');
if (!form.html_content.trim()) return toast.error('HTML-Inhalt fehlt');
setSaving(true);
try {
if (modal.mode === 'create') {
await portalGuideService.create(form);
toast.success('Anleitung erstellt');
} else {
await portalGuideService.update(modal.guide.id, form);
toast.success('Anleitung gespeichert');
}
setModal(null);
await load();
} catch (e) {
toast.error(e.response?.data?.error || 'Fehler');
} finally {
setSaving(false);
}
};
const remove = async (g) => {
if (!window.confirm(`"${g.title}" wirklich löschen?`)) return;
try {
await portalGuideService.delete(g.id);
toast.success('Gelöscht');
await load();
} catch {
toast.error('Fehler beim Löschen');
}
};
// ── Viewer ───────────────────────────────────────────────────────────────
if (viewer) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary)' }}>
{/* Viewer Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 20px', background: 'var(--bg-card)', borderBottom: '1px solid var(--border-color)', flexShrink: 0 }}>
<button
onClick={() => setViewer(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cereda-primary)', fontSize: '20px', lineHeight: 1, padding: '4px' }}
>
</button>
<span style={{ fontSize: '18px' }}>{viewer.icon}</span>
<div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '16px' }}>{viewer.title}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{viewer.category}</div>
</div>
{canAdmin && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary btn-sm" onClick={() => { setViewer(null); setViewerHtml(''); openEdit(viewer); }}>Bearbeiten</button>
</div>
)}
</div>
{/* HTML Viewer */}
{viewerHtml ? (
<iframe
srcDoc={viewerHtml}
style={{ flex: 1, border: 'none', background: '#fff' }}
title={viewer.title}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
/>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#64748b' }}>Laden...</div>
</div>
)}
</div>
);
}
// ── Main List ────────────────────────────────────────────────────────────
return (
<div style={{ padding: '28px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '24px', fontWeight: 800 }}>Portal</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '14px' }}>Anleitungen und Dokumentationen</p>
</div>
{canAdmin && (
<button className="btn btn-primary" onClick={openCreate}>+ Anleitung hochladen</button>
)}
</div>
{/* Filter Row */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="form-input"
placeholder="Suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: '220px' }}
/>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
style={{
padding: '6px 14px',
borderRadius: '20px',
border: categoryFilter === cat ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: categoryFilter === cat ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: categoryFilter === cat ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: categoryFilter === cat ? 700 : 400,
cursor: 'pointer',
fontSize: '13px',
}}
>
{cat === 'all' ? 'Alle' : cat}
</button>
))}
</div>
</div>
{/* Guides Grid */}
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Laden...</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '48px', marginBottom: '12px' }}>📄</div>
<div style={{ fontSize: '16px' }}>Keine Anleitungen vorhanden</div>
{canAdmin && <div style={{ fontSize: '13px', marginTop: '6px' }}>Klicke auf "+ Anleitung hochladen" um zu beginnen</div>}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{filtered.map(g => (
<div
key={g.id}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '14px',
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s, transform 0.15s',
position: 'relative',
}}
onClick={() => openViewer(g)}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--cereda-primary)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; e.currentTarget.style.transform = ''; }}
>
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{g.icon || '📄'}</div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '15px', marginBottom: '6px' }}>{g.title}</div>
{g.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '13px', marginBottom: '10px', lineHeight: 1.4 }}>
{g.description.length > 80 ? g.description.slice(0, 80) + '…' : g.description}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{
background: 'rgba(13,148,136,0.1)',
color: 'var(--cereda-primary)',
borderRadius: '20px',
padding: '3px 10px',
fontSize: '11px',
fontWeight: 600,
}}>
{g.category}
</span>
{(() => {
let vr = [];
try { vr = JSON.parse(g.visible_roles || '[]'); } catch {}
if (vr.length > 0) {
const groupNames = ROLE_GROUPS.filter(rg => rg.roles.some(r => vr.includes(r))).map(rg => rg.label);
return <span title={`Nur für: ${groupNames.join(', ')}`} style={{ fontSize: 13, color: 'var(--text-muted)' }}>🔒</span>;
}
return null;
})()}
{canAdmin && (
<div style={{ display: 'flex', gap: '4px' }} onClick={e => e.stopPropagation()}>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '3px 8px', fontSize: '11px' }}
onClick={() => openEdit(g)}
></button>
<button
style={{ padding: '3px 8px', fontSize: '11px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => remove(g)}
>🗑</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Upload / Edit Modal */}
{modal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.65)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '20px' }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: '16px', padding: '28px', width: '100%', maxWidth: '580px', border: '1px solid var(--border-color)', maxHeight: '90vh', overflowY: 'auto' }}>
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)', fontSize: '18px' }}>
{modal.mode === 'create' ? '📄 Anleitung hochladen' : '✏️ Anleitung bearbeiten'}
</h3>
{/* Icon Picker */}
<label style={labelStyle}>Icon</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '16px' }}>
{ICONS.map(ic => (
<button
key={ic}
type="button"
onClick={() => setForm(f => ({ ...f, icon: ic }))}
style={{
fontSize: '20px',
padding: '6px 8px',
border: form.icon === ic ? '2px solid var(--cereda-primary)' : '1px solid var(--border-color)',
borderRadius: '8px',
background: form.icon === ic ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
cursor: 'pointer',
}}
>{ic}</button>
))}
</div>
{/* Title */}
<label style={labelStyle}>Titel *</label>
<input className="form-input" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="z.B. SelectLine Einführung" style={{ marginBottom: '12px' }} />
{/* Category */}
<label style={labelStyle}>Kategorie</label>
<select className="form-select" value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))} style={{ marginBottom: '12px' }}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{/* Description */}
<label style={labelStyle}>Kurzbeschreibung (optional)</label>
<textarea className="form-input" rows={2} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Worum geht es in dieser Anleitung?" style={{ marginBottom: '16px', resize: 'vertical' }} />
{/* Visibility */}
<label style={labelStyle}>Sichtbar für</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
<button
type="button"
onClick={() => setForm(f => ({ ...f, visible_roles: [] }))}
style={{
padding: '7px 14px', borderRadius: 20, border: form.visible_roles.length === 0 ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: form.visible_roles.length === 0 ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: form.visible_roles.length === 0 ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: form.visible_roles.length === 0 ? 700 : 400, cursor: 'pointer', fontSize: 13,
}}
>🌐 Alle</button>
{ROLE_GROUPS.map(g => {
const active = g.roles.every(r => form.visible_roles.includes(r)) || g.roles.some(r => form.visible_roles.includes(r));
return (
<button
key={g.label}
type="button"
onClick={() => setForm(f => {
const newRoles = active
? f.visible_roles.filter(r => !g.roles.includes(r))
: [...new Set([...f.visible_roles, ...g.roles])];
return { ...f, visible_roles: newRoles };
})}
style={{
padding: '7px 14px', borderRadius: 20,
border: active ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: active ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: active ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: active ? 700 : 400, cursor: 'pointer', fontSize: 13,
}}
>{g.icon} {g.label}</button>
);
})}
</div>
{/* File Upload */}
<label style={labelStyle}>HTML-Datei *</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '8px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => fileInputRef.current?.click()}
>
📂 Datei auswählen
</button>
{form.html_content && (
<span style={{ color: 'var(--cereda-primary)', fontSize: '13px', fontWeight: 600 }}>
Datei geladen ({Math.round(form.html_content.length / 1024)} KB)
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".html,.htm"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{!form.html_content && (
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: '0 0 16px' }}>
Wähle eine fertige .html Datei aus
</p>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '8px' }}>
<button className="btn btn-secondary" onClick={() => setModal(null)}>Abbrechen</button>
<button className="btn btn-primary" disabled={saving || !form.title.trim() || !form.html_content.trim()} onClick={save}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const labelStyle = {
display: 'block',
marginBottom: '6px',
fontSize: '13px',
color: 'var(--text-secondary)',
fontWeight: 600,
};