411 lines
22 KiB
JavaScript
411 lines
22 KiB
JavaScript
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,
|
||
};
|