Files
IT-Nexus/frontend/src/pages/PortalPage.jsx

411 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};